Merge remote-tracking branch 'origin/main' into hf/like-index-pushdown

# Conflicts:
#	src/config/src/datafusion/request.rs
#	src/search_service/src/lib.rs
This commit is contained in:
hengfeiyang
2026-11-04 23:13:08 +08:00
132 changed files with 15060 additions and 2808 deletions
+1
View File
@@ -20,6 +20,7 @@ ignore = [
"RUSTSEC-2026-0367", # from zip 0.6 (chromiumoxide_fetcher, unpacks the Chromium download) and zip 3 (utoipa-swagger-ui build script); fix requires zip 9, no upgrade path upstream
"RUSTSEC-2026-0368", # from zip 0.6/3, same sources as RUSTSEC-2026-0367; only trusted archives are extracted
"RUSTSEC-2026-0369", # from zip 0.6/3, same sources as RUSTSEC-2026-0367; only trusted archives are extracted
"RUSTSEC-2026-0429", # from zip 0.6/3, same sources as RUSTSEC-2026-0367; only trusted archives are extracted; fix requires zip 9
"RUSTSEC-2026-0391", # from zstd 0.13, used by arrow, parquet, datafusion and tonic; the fix is in zstd 0.14, which they have not adopted
"RUSTSEC-2026-0398", # from zstd-safe 7, the zstd 0.13 line above; fixed in zstd-safe 8, which needs zstd 0.14
"RUSTSEC-2026-0403", # from zstd-safe 7, the zstd 0.13 line above; fixed in zstd-safe 8, which needs zstd 0.14
Generated
+5 -4
View File
@@ -6690,9 +6690,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.29"
version = "1.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9"
checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2"
dependencies = [
"cc",
"pkg-config",
@@ -8432,6 +8432,7 @@ dependencies = [
"tokio",
"tokio-util",
"tonic",
"tower",
"tracing",
"tracing-opentelemetry",
"transform",
@@ -9870,9 +9871,9 @@ dependencies = [
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b"
dependencies = [
"bitflags 2.13.1",
"memchr",
+171 -51
View File
@@ -252,22 +252,6 @@
"feature_traces": "998741a58ed2b411",
"feature_vrl_functions": "4484dd94e9355480",
"feature_workload_management": "8004f04c30d8bddc",
"features": "5697d03daef4de9c",
"features_all_unlocked": "0791a3840eb6061a",
"features_filter_all": "a52ace420f2175d0",
"features_filter_free": "f411a1fb62758b4c",
"features_filter_new": "18fdd549b2ed367a",
"features_free_heading": "5892004a2a606ee5",
"features_group_heading": "98bb11ae86a0c557",
"features_missing": "694943b3db420a2e",
"features_new": "d9fe8aa562864fe0",
"features_no_match": "c7655586749bb9b3",
"features_none": "6b474f2c0cba33e7",
"features_paid_ratio": "3257b4364ee32149",
"features_request_access": "22d6edf4b21d5e25",
"features_search": "55e327604647ce88",
"features_summary": "5d2957d8f8c9d8e7",
"features_unlocked": "531c7b28abf27600",
"foreverNoLimits": "c4e4f35814b1022b",
"fullyManaged": "49964f86a467ca7b",
"gbPerDay": "dd9d1b63afeb0945",
@@ -13076,19 +13060,6 @@
"tabLabel": "cb702378f31507ef",
"title": "2a932f90172e99ea"
},
"featureLock": {
"bannerMessage": "2ad54d7dcf939937",
"bannerMessageGeneric": "9e4736ff1b60331d",
"contactSales": "604abea32b499a4c",
"description": "052f7504abd2c39a",
"headerLocked": "ce0f1697c8a3a6d5",
"routeTitle": "694943b3db420a2e",
"thisFeature": "2598604c06f0d223",
"title": "84027eb92891f1fd",
"toastMessage": "b17241a153486b9c",
"toastMessageGeneric": "4cf1f345d3caeb96",
"toastTitle": "feff10da75dad3ed"
},
"fleet": {
"addCollector": "6d30a69cb01ed55a",
"audit": {
@@ -13229,7 +13200,6 @@
"restart": "6b983a81e5e8099e",
"restartManyTitle": "b48d3498ba6feded",
"restartMessage": "22ee1481ad969aff",
"restartOffline": "35842abc83dff75b",
"restartQueued": "43da4197b31c94b2",
"restartUnsupported": "322b4fd3d40b0ff7",
"search": "773656193742d0b1",
@@ -13565,7 +13535,7 @@
"confirmFixForward": "6e986df0401cda4a",
"confirmResume": "323c118fa4613787",
"confirmRollback": "ad34d76a7f53f6b5",
"confirmRollbackNoStable": "5b00aa2620aa662d",
"confirmRollbackNoStable": "76cba5181ffc44a2",
"confirmSkip": "fbf2627372d3e0cc",
"confirmed": "fe00b67b6dd1143f",
"emptyFleet": "40e87c8e8e200c76",
@@ -13626,7 +13596,6 @@
"phaseStopped": "1a4f630ac1b69fd0",
"phaseTimedOut": "9718cc761cfd5810",
"phaseTimeoutNote": "f11dc4d3138e81ac",
"phaseTimeoutNoteSeconds": "fafc2b0d6a049798",
"phaseVerifying": "a7d4a9f301edaa61",
"phases": "b4b0b75a774761b3",
"pickConfig": "90ea303d63b6e1b9",
@@ -13693,8 +13662,8 @@
"noAccess": "cb92165578c84ffb",
"offlineHeartbeats": "b82c0b4fc3ceead6",
"offlineHeartbeatsHelp": "91687a5a34666ebf",
"offlineRatio": "98affbf40cf01c0c",
"offlineRatioHelp": "fcf7765ecca25ef0",
"offlineRatio": "ab13785c48fe3a97",
"offlineRatioHelp": "8fa2bcd13b66f2f3",
"outOfRange": "f9a9c12c180ebfe6",
"phaseTimeout": "47d919e06d87345e",
"phaseTimeoutHelp": "019f8feb0a24eaa2",
@@ -20357,32 +20326,109 @@
"updateOrganization": "f927b01e6f33a249"
},
"overview": {
"activeAnomalies": "e96dd052f0d944fa",
"activeIncidents": "f5239d5e7c01c5a5",
"activeAnomalies": "981ade4159cacdec",
"activeIncidents": "0d5b14c485598692",
"alerts": {
"criticalLevel": "78282475e12bce5a",
"emptyAction": "0fd0bf350a56af51",
"emptyDesc": "8627367fc7f62316",
"emptyTitle": "567533d895c27065",
"firingFor": "11cb9d91c8532103",
"firingNow": "c9061aa892c72868",
"noneFiring": "081fc480527c69f5",
"rulesActive": "72fdbccd8fb3aed6",
"rulesEvaluated": "9fedbef3944b2a0e",
"rulesFailing": "3212b6c9a5d4a814",
"rulesFailingToEvaluate": "2b3301a6487190ce",
"showMore": "1557a0aa0041cf6e",
"subtitle": "c2a5c180dd02641a",
"viewAll": "1553d761825d0509",
"warningLevel": "d7322f65981a285b"
},
"alertsSuffix": "deb3e366c077c228",
"allClear": "22d352f617758292",
"allClearDesc": "bfe3044f55b21533",
"allClearDescScoped": "7c66a0cc083a98ba",
"allClearPartial": "f5a637211cc64316",
"allClearPartialDesc": "e5ed97f9af61f860",
"anomalyDetectedDescription": "67a554a9305c968d",
"anomalyDetectedTitle": "42b3e5f78e8b2967",
"areaAlerts": "f62e9267d196c761",
"areaAnomalies": "a0a660cc9daa76b9",
"areaIncidents": "bfe868931580fb3b",
"areaServices": "604dce445e326d95",
"cards": {
"criticalUnit": "da88d8560092eff8",
"deliveryFailures": "3295bdbfe78e3c01",
"deliveryNote": "31f287d5888f7ae8",
"failingNone": "d6a57f4f5d585d7f",
"failingNote": "797d69b6351b9d99",
"failingRules": "b1829503b811b86f",
"firingAlerts": "d9e293a211a1aeab",
"firingNone": "b3c57801a954c8df",
"incidentsNone": "1b303835cec87ac7",
"noAlertRules": "7364c4d5c6836fda",
"noEnabledRules": "3113508de7297ef5",
"noPipelines": "29cf5a22b101cdea",
"noStreams": "06e3aaafc23cc49c",
"oldestFiring": "4642274888e72100",
"openIncidents": "0021b3e95793e696",
"pipelineErrors": "ee50a619caee4c07",
"pipelinesOf": "6f1293ff044ce6fb",
"quietNote": "8e9fd378a75b7afc",
"quietStreams": "254025432ea334ec"
},
"colErrorRate": "33a224722fc7b1e4",
"colLatency": "e0e7d293043c8cf6",
"colReqs": "61985debc6417c53",
"dataAsOf": "559ef63277c32024",
"dataHealth": {
"compaction": "ea4d27f706d82e1a",
"compactionNote": "781dd2c3af67a05b",
"delivery": "3295bdbfe78e3c01",
"deliveryNote": "31f287d5888f7ae8",
"failing": "276f4b0bff63da18",
"failingNote": "4476ec65febe1e7a",
"noPipelines": "72183d31fea778ed",
"pipelinesActive": "ebc50e31e444d623",
"pipelinesErrored": "c8b743fe3a5980ab",
"pipelinesNote": "6cb0fae496a12e5e",
"quiet": "0a6365d180913968",
"quietNote": "e9aa7568a24f29e1",
"schema": "03a2d5849d4086cb",
"schemaNote": "9b199e7827a03769",
"status": {
"healthy": "7f1e323b6272c620",
"info": "170322a32f3c35b2",
"needsAction": "e382d466c25613b6",
"notSetUp": "94b768af74236a67",
"warning": "e981ddae45d8f4ca"
},
"subtitle": "f7b738721f218463",
"viewPipelines": "da5dae86bafae8a8"
},
"daysAgo": "a41234397f3c63b2",
"emptyActionAlerts": "6ac085a35d9963e7",
"emptyActionAlertsDesc": "e6bc20e23b0b8156",
"empty": {
"anomalies": "bea29d1c1f129a3e",
"anomalyNoChecks": "b33b808d6c169339",
"incidents": "4d3495d2550aef11",
"recentEvents": "b3d8551f4c8f3f2d",
"services": "da1d75df527ee5a7"
},
"emptyActionLogs": "3f8105f14af710a4",
"emptyActionLogsDesc": "635d266e4ad84bde",
"emptyActionTraces": "5a292d4771830cc9",
"emptyActionTracesDesc": "94a3bd51c2b7b54e",
"hoursAgo": "520c737ea62430c4",
"investigate": "289ddb3c038edede",
"level": {
"critical": "427dd2969bd140be",
"okFiring": "1d99d0eaede7d441",
"warning": "e981ddae45d8f4ca"
},
"limit": {
"approachingLimit": "9f01b44bd1611c17",
"blockNote": "db801da4ce61853e",
"daysOver": "ac9d0939417258ff",
"daysOverOf": "8557fdf7bd6b3d31",
"expiredLine": "5de72173d91c820a",
"ofLimit": "6303f8c21765ae2a",
"ofUnlimited": "05cdf654143fbde8",
"overLimit": "66a81e7aafc36cc6",
"resets": "f11e2b11d0662d61",
"subtitle": "66ed51c1f474925a",
"unavailable": "c29fa9075eeecae4",
"used": "ffc22437b1c278a7"
},
"minutesAgo": "0a03167c208c5cd7",
"notMonitoredAlert": "0fd0bf350a56af51",
"notMonitoredAlertDesc": "0e7bd079c07ed0b8",
@@ -20390,14 +20436,88 @@
"notMonitoredAnomalyDesc": "523aa0a36890ff4c",
"notMonitoredDesc": "da8cc880b82d0c93",
"notMonitoredTitle": "5ed64bcad471cff7",
"recentEvents": "9acf8ff6c2901930",
"recentEvents": "8ecce94dc301d033",
"scrollLeft": "73befbd7d4f0f62a",
"scrollRight": "6d42701317bf1cc0",
"secondsAgo": "244073ecb2be8fe8",
"sections": {
"alerts": "f62e9267d196c761",
"alertsAndIncidents": "3421fbf2a4841e49",
"dataHealth": "6e5625b8c13013a0",
"limitAndStorage": "5fe708f05c7e2cc3",
"storage": "0db6a0a3bbfd6e3b",
"topStreams": "bb3ca650f2b0aa42"
},
"services": "604dce445e326d95",
"showingOf": "f3d25c9265aac7c1",
"signals": {
"events": "8d14f6e72de8f18a",
"logs": "ea2100dc89ae9fe2",
"metrics": "a58da793c7250f1b",
"open": "ed077f3d8125d60d",
"openAria": {
"logs": "591889ac42e04819",
"metrics": "4975865997142959",
"traces": "ffde39c05cfe85ff"
},
"samples": "bc228b269ce0d87f",
"spans": "b3393bfb2a55adff",
"stored": "0db6a0a3bbfd6e3b",
"streams": "bfd297afbf9e7430",
"traces": "998741a58ed2b411"
},
"stale": {
"message": "ae19c182cdc829fe",
"retry": "942087cc2d41e013"
},
"state": {
"sectionForbidden": "f03bf794d964b129",
"sectionLocked": "2fc5a6a63df6538b",
"tileError": "81bc72e54792e9fc",
"tileForbidden": "9b1f0823459d06f7",
"tileLocked": "a424e33d90931d1e",
"tileUnavailable": "67a926f7008e2b0e"
},
"status": {
"attention": "c1ebc7817870e5be",
"checking": "ec963ffc911b8401",
"clear": "21ad0f972eb181fc",
"items": {
"failing": "36a2f30f9248ca02",
"firing": "5591cea01bf2d35e",
"incidents": "c61409d4f4cddc66",
"pipelines": "eb13ac2dc3a4b5d7",
"quiet": "1cac853c1d0dd6d3"
},
"partial": "279219da8364b5fa",
"unmonitored": "f0a10061d1008a98"
},
"storage": {
"bySignal": "7fe6b7b7ffaad7f4",
"emptyTitle": "e2e18a767a2b6db5",
"splitNote": "4a533a47757998fd",
"used": "0db6a0a3bbfd6e3b"
},
"streams": {
"allStreams": "7ba01d16e615fc96",
"emptyDesc": "f100c4b2cb3c6942",
"emptyTitle": "eddceedac16ae5a7",
"shareOfTotal": "5892fce97211d1ec",
"shareUnderOne": "b540ca1185a12840",
"startIngesting": "ccd00cde6f176b41",
"subtitle": "0473127f6a49ead0",
"typeShare": "a1d4e3f6c7380584"
},
"time": {
"daysHours": "be43ecf3668b6b23",
"hoursMinutes": "425dc6c72de6fdc5",
"minutes": "f7047179845887e8"
},
"timeRange": {
"heading": "b7fdbb97bb7418c0"
},
"timesSymbol": "8db71ed28b0f2f14",
"untitledIncident": "4bea619d75d7fa3f",
"untitledIncident": "a3e4994bcd734dd5",
"viewAll": "30a64216eaeac5d3",
"viewingLabel": "b28f71ee763aebbe"
},
+345 -105
View File
@@ -63,6 +63,8 @@ pub struct RequestData {
pub uri: Uri,
pub method: Method,
pub headers: HeaderMap,
/// The route template axum matched (`MatchedPath`); ingestion is classified by it.
pub route: Option<String>,
}
/// Error type for auth validation
@@ -214,6 +216,7 @@ pub async fn validator(
user_id,
password.trim(),
path,
req_data.route.as_deref(),
&req_data.method,
from_session,
)
@@ -222,7 +225,8 @@ pub async fn validator(
Ok(res) => {
if res.is_valid {
// Check and create organization if needed
check_and_create_org(user_id, &req_data.method, path).await?;
check_and_create_org(user_id, &req_data.method, path, req_data.route.as_deref())
.await?;
#[cfg(feature = "enterprise")]
let path = path.to_owned();
@@ -388,9 +392,10 @@ async fn fleet_token_response(
path_org: Option<&str>,
method: &Method,
path: &str,
route: Option<&str>,
) -> TokenValidationResponse {
let fleet_enabled = openobserve_core::fleet::enabled();
fleet_token_response_with(fleet_enabled, user_id, token, path_org, method, path).await
fleet_token_response_with(fleet_enabled, user_id, token, path_org, method, path, route).await
}
#[cfg(not(feature = "enterprise"))]
@@ -400,6 +405,7 @@ async fn fleet_token_response(
_path_org: Option<&str>,
_method: &Method,
_path: &str,
_route: Option<&str>,
) -> TokenValidationResponse {
TokenValidationResponse::default()
}
@@ -412,10 +418,11 @@ async fn fleet_token_response_with(
path_org: Option<&str>,
method: &Method,
path: &str,
route: Option<&str>,
) -> TokenValidationResponse {
use o2_enterprise::enterprise::fleet::enrollment::ENROLLMENT;
let ingestion_write = ingestion_routes::is_ingestion_write(method, path);
let ingestion_write = ingestion_routes::is_ingestion_write(method, path, route);
match ENROLLMENT
.accept(fleet_enabled, ingestion_write, token, path_org)
.await
@@ -437,6 +444,7 @@ pub async fn validate_credentials(
user_id: &str,
user_password: &str,
path: &str,
route: Option<&str>,
method: &Method,
from_session: bool,
) -> Result<TokenValidationResponse, AuthError> {
@@ -498,6 +506,7 @@ pub async fn validate_credentials(
path_columns.first().copied(),
method,
path,
route,
)
.await);
}
@@ -508,15 +517,13 @@ pub async fn validate_credentials(
// method, so `GET /{org}/{stream}/traces/latest` (a data-read route that
// merely contains the `traces` segment) leaked protected data. The
// authoritative ingestion-route table classifies the request by method and
// exact path shape, so only real writes and the read-only ES handshake
// stubs are accepted — data-read routes are not in the table. `path` (not
// the trailing-slash-trimmed `path_columns`) is passed so the ES root ping
// `GET /{org}/` stays distinguishable from `/organizations` etc.
// matched route template, so only real writes and the read-only ES handshake
// stubs are accepted — data-read routes are not in the table.
// OpAMP accepts fleet tokens only, which returned above.
if ingestion_routes::is_opamp(path) {
return Ok(TokenValidationResponse::default());
}
let is_ingestion_path = ingestion_routes::is_ingestion_allowed(method, path);
let is_ingestion_path = ingestion_routes::is_ingestion_allowed(method, path, route);
// Check org-level ingestion tokens before user lookup.
// Org tokens are prefixed with "o2oi_" for fast identification —
@@ -940,7 +947,12 @@ pub async fn validate_credentials_ext(
/// - The org does not exist in the meta table
/// - The user is a root user
/// - This is a ingestion POST endpoint
async fn check_and_create_org(user_id: &str, method: &Method, path: &str) -> Result<(), AuthError> {
async fn check_and_create_org(
user_id: &str,
method: &Method,
path: &str,
route: Option<&str>,
) -> Result<(), AuthError> {
let cfg = get_config();
let mut path_columns = path.split('/').collect::<Vec<&str>>();
if let Some(v) = path_columns.first()
@@ -972,7 +984,7 @@ async fn check_and_create_org(user_id: &str, method: &Method, path: &str) -> Res
if !cfg.common.create_org_through_ingestion {
Err(AuthError::NotFound("Organization not found".to_string()))
} else if is_root_user(user_id)
&& ingestion_routes::is_ingestion_write(method, path)
&& ingestion_routes::is_ingestion_write(method, path, route)
&& openobserve_core::organization::check_and_create_org(org_id)
.await
.inspect_err(|error| {
@@ -1132,7 +1144,15 @@ pub async fn validator_aws(req_data: &RequestData) -> Result<AuthValidationResul
return Err(AuthError::Unauthorized("Unauthorized Access".to_string()));
};
match validate_credentials(&user_id, &password, path, &req_data.method, false).await
match validate_credentials(
&user_id,
&password,
path,
req_data.route.as_deref(),
&req_data.method,
false,
)
.await
{
Ok(res) => {
if res.is_valid {
@@ -1177,7 +1197,16 @@ pub async fn validator_gcp(req_data: &RequestData) -> Result<AuthValidationResul
return Err(AuthError::Unauthorized("Unauthorized Access".to_string()));
};
match validate_credentials(&user_id, &password, path, &req_data.method, false).await {
match validate_credentials(
&user_id,
&password,
path,
req_data.route.as_deref(),
&req_data.method,
false,
)
.await
{
Ok(res) => {
if res.is_valid {
Ok(AuthValidationResult {
@@ -1525,6 +1554,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
// This holds structurally — `Splunk` matches no branch in
// oo_validator_internal and falls through to Unauthorized.
let req_data = RequestData {
route: Some("/api/{org_id}/_bulk".to_string()),
uri: "/orgb/_bulk".parse::<Uri>().unwrap(),
method: Method::POST,
headers: HeaderMap::new(),
@@ -1550,6 +1580,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
async fn auth_ext_token_without_its_prefix_is_rejected() {
let now = Utc::now().timestamp();
let req_data = RequestData {
route: Some("/api/{org_id}/streams".to_string()),
uri: "/default/streams".parse::<Uri>().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
@@ -1579,6 +1610,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
let mut headers = HeaderMap::new();
headers.insert("X-Amz-Firehose-Access-Key", encoded.parse().unwrap());
let aws = RequestData {
route: Some("/aws/{org_id}/{stream_name}/_kinesis_firehose".to_string()),
uri: "/default/mystream/_kinesis_firehose"
.parse::<Uri>()
.unwrap(),
@@ -1594,6 +1626,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
.append_pair("API-Key", &encoded)
.finish();
let gcp = RequestData {
route: Some("/gcp/{org_id}/{stream_name}/_sub".to_string()),
uri: format!("/default/mystream/_sub?{query}")
.parse::<Uri>()
.unwrap(),
@@ -1611,6 +1644,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
// A GUID is not an `o2oi_` token and is not a user password, so Basic
// with it must fail too.
let req_data = RequestData {
route: Some("/api/{org_id}/_bulk".to_string()),
uri: "/orgb/_bulk".parse::<Uri>().unwrap(),
method: Method::POST,
headers: HeaderMap::new(),
@@ -1838,19 +1872,41 @@ CN5lTowpVXP2OIiiNTGa75Fj
ORG_INGESTION_TOKENS.insert(format!("{org_id}/{token}"), "regression-token".to_string());
// --- The bypass: data-bearing GET reads must be rejected. ---
for path in [
"default/mystream/traces/latest", // the exact route from the report
"default/mystream/traces/latest_stream",
"default/mystream/traces/session",
"default/mystream/traces/user",
"default/logs/_values",
"default/metrics/latest",
for (path, route) in [
(
"default/mystream/traces/latest", // the exact route from the report
"/api/{org_id}/{stream_name}/traces/latest",
),
(
"default/mystream/traces/latest_stream",
"/api/{org_id}/{stream_name}/traces/latest_stream",
),
(
"default/mystream/traces/session",
"/api/{org_id}/{stream_name}/traces/session",
),
(
"default/mystream/traces/user",
"/api/{org_id}/{stream_name}/traces/user",
),
(
"default/logs/_values",
"/api/{org_id}/{stream_name}/_values",
),
("default/metrics/latest", "/api/{org_id}/metrics/latest"),
] {
assert!(
!validate_credentials("collector@example.com", token, path, &Method::GET, false,)
.await
.unwrap()
.is_valid,
!validate_credentials(
"collector@example.com",
token,
path,
Some(route),
&Method::GET,
false,
)
.await
.unwrap()
.is_valid,
"org ingestion token must not read GET /{path}"
);
}
@@ -1859,25 +1915,50 @@ CN5lTowpVXP2OIiiNTGa75Fj
// template/data-stream/ingest-pipeline POST-create stubs (whose `{name}`
// is the last segment, so the old last-segment rule would have missed
// them — the route table matches them by shape). ---
for path in [
"default/traces", // OTLP/native traces write
"default/_bulk", // ES bulk
"default/mystream/_json", // JSON ingest
"default/_hec", // Splunk HEC
"default/loki/api/v1/push", // Loki push
"default/v1/logs", // OTLP logs
"default/prometheus/api/v1/write", // Prometheus remote-write
"default/mystream/_kinesis_firehose", // AWS Firehose
"default/mystream/_sub", // GCP Pub/Sub push
"default/_index_template/filebeat-7", // ES template create
"default/_data_stream/filebeat-7", // ES data-stream create
"default/_ingest/pipeline/filebeat-7", // ES ingest-pipeline create
for (path, route) in [
("default/traces", "/api/{org_id}/traces"),
("default/_bulk", "/api/{org_id}/_bulk"),
(
"default/mystream/_json",
"/api/{org_id}/{stream_name}/_json",
),
("default/_hec", "/api/{org_id}/_hec"),
("default/loki/api/v1/push", "/api/{org_id}/loki/api/v1/push"),
("default/v1/logs", "/api/{org_id}/v1/logs"),
(
"default/prometheus/api/v1/write",
"/api/{org_id}/prometheus/api/v1/write",
),
(
"default/mystream/_kinesis_firehose",
"/aws/{org_id}/{stream_name}/_kinesis_firehose",
),
("default/mystream/_sub", "/gcp/{org_id}/{stream_name}/_sub"),
(
"default/_index_template/filebeat-7",
"/api/{org_id}/_index_template/{name}",
),
(
"default/_data_stream/filebeat-7",
"/api/{org_id}/_data_stream/{name}",
),
(
"default/_ingest/pipeline/filebeat-7",
"/api/{org_id}/_ingest/pipeline/{name}",
),
] {
assert!(
validate_credentials("collector@example.com", token, path, &Method::POST, false,)
.await
.unwrap()
.is_valid,
validate_credentials(
"collector@example.com",
token,
path,
Some(route),
&Method::POST,
false,
)
.await
.unwrap()
.is_valid,
"org ingestion token must be accepted on POST /{path}"
);
}
@@ -1889,6 +1970,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
"collector@example.com",
token,
"default",
Some("/api/{org_id}"),
&Method::POST,
false,
)
@@ -1901,21 +1983,44 @@ CN5lTowpVXP2OIiiNTGa75Fj
// --- Reads: only the static Elasticsearch handshake/setup stubs (and
// the bare ES root ping) are reachable so ES ingestion clients connect
// and load templates. These return no user data. ---
for (path, method) in [
("default/", Method::GET), // ES root version ping `GET /{org}/`
("default/", Method::HEAD), // ES root ping via HEAD
("default/_license", Method::GET),
("default/_xpack", Method::GET),
("default/_ilm/policy/filebeat-7", Method::GET),
("default/_index_template/filebeat-7", Method::GET),
("default/_data_stream/filebeat-7", Method::HEAD),
("default/_ingest/pipeline/filebeat-7", Method::GET),
for (path, route, method) in [
("default/", "/api/{org_id}/", Method::GET),
("default/", "/api/{org_id}/", Method::HEAD),
("default/_license", "/api/{org_id}/_license", Method::GET),
("default/_xpack", "/api/{org_id}/_xpack", Method::GET),
(
"default/_ilm/policy/filebeat-7",
"/api/{org_id}/_ilm/policy/{name}",
Method::GET,
),
(
"default/_index_template/filebeat-7",
"/api/{org_id}/_index_template/{name}",
Method::GET,
),
(
"default/_data_stream/filebeat-7",
"/api/{org_id}/_data_stream/{name}",
Method::HEAD,
),
(
"default/_ingest/pipeline/filebeat-7",
"/api/{org_id}/_ingest/pipeline/{name}",
Method::GET,
),
] {
assert!(
validate_credentials("collector@example.com", token, path, &method, false,)
.await
.unwrap()
.is_valid,
validate_credentials(
"collector@example.com",
token,
path,
Some(route),
&method,
false,
)
.await
.unwrap()
.is_valid,
"org ingestion token must be accepted on {method} /{path}"
);
}
@@ -1928,6 +2033,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
"collector@example.com",
token,
"default",
Some("/api/{org_id}"),
&Method::GET,
false,
)
@@ -1944,6 +2050,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
"collector@example.com",
token,
"default/dashboards",
Some("/api/{org_id}/dashboards"),
&Method::POST,
false,
)
@@ -1961,6 +2068,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
"collector@example.com",
token,
"default/traces",
Some("/api/{org_id}/traces"),
&method,
false,
)
@@ -1978,6 +2086,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
"collector@example.com",
token,
"default/v1/opamp",
Some("/api/{org_id}/v1/opamp"),
&method,
false,
)
@@ -1988,6 +2097,34 @@ CN5lTowpVXP2OIiiNTGa75Fj
);
}
// A management write whose last segment is an ingestion literal is not ingestion.
for (path, route) in [
("default/kv/_json", "/api/{org_id}/kv/{key}"),
(
"default/streams/_sub",
"/api/{org_id}/streams/{stream_name}",
),
(
"default/enrichment_tables/_sub",
"/api/{org_id}/enrichment_tables/{table_name}",
),
] {
assert!(
!validate_credentials(
"collector@example.com",
token,
path,
Some(route),
&Method::POST,
false,
)
.await
.unwrap()
.is_valid,
"org ingestion token must not be accepted on POST {route} as /{path}"
);
}
ORG_INGESTION_TOKENS.remove(&format!("{org_id}/{token}"));
}
@@ -2011,21 +2148,29 @@ CN5lTowpVXP2OIiiNTGa75Fj
let hash = hash_token;
ENROLLMENT.cache_token(hash(live), cached("live", false));
ENROLLMENT.cache_token(hash(revoked), cached("revoked", true));
let check = |enabled: bool, token: &'static str, method: Method, path: &'static str| async move {
let check = |enabled: bool,
token: &'static str,
method: Method,
path: &'static str,
route: &'static str| async move {
let org = path.split('/').next();
fleet_token_response_with(enabled, "agent", token, org, &method, path)
fleet_token_response_with(enabled, "agent", token, org, &method, path, Some(route))
.await
.is_valid
};
for (method, path) in [
(Method::GET, "default/v1/opamp"),
(Method::POST, "default/v1/opamp"),
(Method::POST, "default/v1/logs"),
(Method::POST, "default/v1/metrics"),
for (method, path, route) in [
(Method::GET, "default/v1/opamp", "/api/{org_id}/v1/opamp"),
(Method::POST, "default/v1/opamp", "/api/{org_id}/v1/opamp"),
(Method::POST, "default/v1/logs", "/api/{org_id}/v1/logs"),
(
Method::POST,
"default/v1/metrics",
"/api/{org_id}/v1/metrics",
),
] {
assert!(
check(true, live, method.clone(), path).await,
check(true, live, method.clone(), path, route).await,
"{method} /{path}"
);
}
@@ -2036,6 +2181,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
Some("default"),
&Method::POST,
"default/v1/opamp",
Some("/api/{org_id}/v1/opamp"),
)
.await;
assert_eq!(
@@ -2048,18 +2194,24 @@ CN5lTowpVXP2OIiiNTGa75Fj
);
assert_eq!(valid.given_name, "fleet-live");
for (method, path) in [
(Method::POST, "other/v1/opamp"),
(Method::GET, "default/mystream/traces/latest"),
(Method::GET, "default/"),
let opamp = "/api/{org_id}/v1/opamp";
for (method, path, route) in [
(Method::POST, "other/v1/opamp", opamp),
(
Method::GET,
"default/mystream/traces/latest",
"/api/{org_id}/{stream_name}/traces/latest",
),
(Method::GET, "default/", "/api/{org_id}/"),
(Method::POST, "default/kv/_json", "/api/{org_id}/kv/{key}"),
] {
assert!(
!check(true, live, method.clone(), path).await,
!check(true, live, method.clone(), path, route).await,
"{method} /{path}"
);
}
assert!(!check(true, revoked, Method::POST, "default/v1/opamp").await);
assert!(!check(false, live, Method::POST, "default/v1/opamp").await);
assert!(!check(true, revoked, Method::POST, "default/v1/opamp", opamp).await);
assert!(!check(false, live, Method::POST, "default/v1/opamp", opamp).await);
}
#[tokio::test]
@@ -2093,6 +2245,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
// token must still be rejected: bypass_check must not double as
// from_session and skip the allow_static_token policy.
let req_data = RequestData {
route: None,
uri: "/default/config".parse().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
@@ -2108,7 +2261,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
// The same credentials through an assume_service_account session
// (from_session=true) are accepted.
assert!(
validate_credentials(sa_email, token, "default/config", &Method::GET, true)
validate_credentials(sa_email, token, "default/config", None, &Method::GET, true)
.await
.unwrap()
.is_valid,
@@ -2143,6 +2296,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
let credentials = base64::encode(&format!("{sa_email}:{token}"));
let req_data = RequestData {
route: None,
uri: "/default/users".parse().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
@@ -2245,31 +2399,31 @@ CN5lTowpVXP2OIiiNTGa75Fj
.await;
assert!(
validate_credentials(init_user, pwd, "default/_bulk", &Method::POST, false)
validate_credentials(init_user, pwd, "default/_bulk", None, &Method::POST, false)
.await
.unwrap()
.is_valid
);
assert!(
!validate_credentials("", pwd, "default/_bulk", &Method::POST, false)
!validate_credentials("", pwd, "default/_bulk", None, &Method::POST, false)
.await
.unwrap()
.is_valid
);
assert!(
!validate_credentials("", pwd, "/", &Method::GET, false)
!validate_credentials("", pwd, "/", None, &Method::GET, false)
.await
.unwrap()
.is_valid
);
assert!(
!validate_credentials(user_id, pwd, "/", &Method::GET, false)
!validate_credentials(user_id, pwd, "/", None, &Method::GET, false)
.await
.unwrap()
.is_valid
);
assert!(
!validate_credentials(user_id, "x", "default/user", &Method::GET, false)
!validate_credentials(user_id, "x", "default/user", None, &Method::GET, false)
.await
.unwrap()
.is_valid
@@ -2277,7 +2431,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
assert!(validate_user(init_user, pwd).await.unwrap().is_valid);
for method in [Method::GET, Method::POST] {
assert!(
!validate_credentials(init_user, pwd, "default/v1/opamp", &method, false)
!validate_credentials(init_user, pwd, "default/v1/opamp", None, &method, false)
.await
.unwrap()
.is_valid,
@@ -2285,12 +2439,12 @@ CN5lTowpVXP2OIiiNTGa75Fj
);
}
let stored_ext = get_hash(pwd, &config::get_ext_auth_salt());
for (path, method) in [
("default/_bulk", Method::POST),
("default/streams", Method::GET),
for (path, route, method) in [
("default/_bulk", "/api/{org_id}/_bulk", Method::POST),
("default/streams", "/api/{org_id}/streams", Method::GET),
] {
assert!(
!validate_credentials(init_user, &stored_ext, path, &method, false)
!validate_credentials(init_user, &stored_ext, path, Some(route), &method, false)
.await
.unwrap()
.is_valid,
@@ -2311,6 +2465,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
init_user,
&static_token,
"default/v1/logs",
Some("/api/{org_id}/v1/logs"),
&Method::POST,
false
)
@@ -2323,6 +2478,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
init_user,
&static_token,
"default/v1/opamp",
Some("/api/{org_id}/v1/opamp"),
&Method::POST,
false
)
@@ -2332,11 +2488,52 @@ CN5lTowpVXP2OIiiNTGa75Fj
"a user's ingestion-only static token must not manage collectors"
);
fleet_prefixed_password_never_reaches_the_user_lookup(org_id, init_user).await;
exercise_root_creates_an_org_only_on_an_ingestion_route(init_user, pwd).await;
#[cfg(feature = "enterprise")]
exercise_lockout(org_id, init_user, pwd).await;
}
async fn exercise_root_creates_an_org_only_on_an_ingestion_route(root_user: &str, pwd: &str) {
// `is_root_user` reads the org-user cache, which nothing populates under test.
ORG_USERS.insert(
format!("{DEFAULT_ORG}/{root_user}"),
infra::table::org_users::OrgUserRecord {
role: config::meta::user::UserRole::Root,
token: "root_token".to_string(),
rum_token: None,
org_id: DEFAULT_ORG.to_string(),
email: root_user.to_string(),
created_at: 0,
allow_static_token: true,
},
);
let credentials = base64::encode(&format!("{root_user}:{pwd}"));
let auth = AuthExtractor::bypass(format!("Basic {credentials}"), String::new());
for (org, uri, route, created) in [
(
"autoviakv",
"/autoviakv/kv/_json",
"/api/{org_id}/kv/{key}",
false,
),
(
"autoviabulk",
"/autoviabulk/_bulk",
"/api/{org_id}/_bulk",
true,
),
] {
let result = oo_validator(&post_request(uri, route, HeaderMap::new()), &auth).await;
assert_eq!(result.is_ok(), created, "root POST {uri}");
assert_eq!(
organization::get_org(org).await.is_some(),
created,
"org {org}"
);
}
}
// NULL `password_ext`, exactly as the SRE-agent service-account migration writes it.
async fn seed_user_without_password_ext(
org_id: &str,
@@ -2394,9 +2591,13 @@ CN5lTowpVXP2OIiiNTGa75Fj
let mut failures = routed_org_failures(&basic, "Basic").await;
let mut firehose = HeaderMap::new();
firehose.insert("X-Amz-Firehose-Access-Key", credentials.parse().unwrap());
if validator_aws(&post_request("/aws/kv/_kinesis_firehose", firehose))
.await
.is_ok()
if validator_aws(&post_request(
"/aws/kv/_kinesis_firehose",
"/aws/{org_id}/{stream_name}/_kinesis_firehose",
firehose,
))
.await
.is_ok()
{
failures.push("POST /aws/aws/kv/_kinesis_firehose: accepted for org aws".to_string());
}
@@ -2405,6 +2606,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
.finish();
if validator_gcp(&post_request(
&format!("/gcp/kv/_sub?{query}"),
"/gcp/{org_id}/{stream_name}/_sub",
HeaderMap::new(),
))
.await
@@ -2420,8 +2622,9 @@ CN5lTowpVXP2OIiiNTGa75Fj
assert!(failures.is_empty(), "{failures:#?}");
}
fn post_request(uri: &str, headers: HeaderMap) -> RequestData {
fn post_request(uri: &str, route: &str, headers: HeaderMap) -> RequestData {
RequestData {
route: Some(route.to_string()),
uri: uri.parse().unwrap(),
method: Method::POST,
headers,
@@ -2430,17 +2633,21 @@ CN5lTowpVXP2OIiiNTGa75Fj
// A member of `kv` is served in `kv` and refused by the orgs named like route segments.
async fn routed_org_failures(auth: &AuthExtractor, scheme: &str) -> Vec<String> {
const KV_ROUTE: &str = "/api/{org_id}/kv/{key}";
let mut failures = vec![];
if oo_validator(&post_request("/kv/kv/note", HeaderMap::new()), auth)
.await
.is_err()
if oo_validator(
&post_request("/kv/kv/note", KV_ROUTE, HeaderMap::new()),
auth,
)
.await
.is_err()
{
failures.push(format!(
"{scheme} POST /api/kv/kv/note: a member of kv was refused in kv"
));
}
for (served, uri) in [("api", "/api/kv/note"), ("v2", "/v2/kv/note")] {
if oo_validator(&post_request(uri, HeaderMap::new()), auth)
if oo_validator(&post_request(uri, KV_ROUTE, HeaderMap::new()), auth)
.await
.is_ok()
{
@@ -2548,9 +2755,16 @@ CN5lTowpVXP2OIiiNTGa75Fj
for guess in ["anything", " "] {
assert!(
!validate_credentials(sso_user, guess, "default/streams", &Method::GET, false)
.await
.is_ok_and(|r| r.is_valid)
!validate_credentials(
sso_user,
guess,
"default/streams",
None,
&Method::GET,
false
)
.await
.is_ok_and(|r| r.is_valid)
);
assert!(
!validate_user(sso_user, guess)
@@ -2560,6 +2774,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
}
let credentials = base64::encode(&format!("{sso_user}:anything"));
let req_data = RequestData {
route: Some("/api/{org_id}/streams".to_string()),
uri: "/default/streams".parse().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
@@ -2580,10 +2795,17 @@ CN5lTowpVXP2OIiiNTGa75Fj
.await;
assert!(
validate_credentials(plain_user, pwd, "default/streams", &Method::GET, false)
.await
.unwrap()
.is_valid,
validate_credentials(
plain_user,
pwd,
"default/streams",
None,
&Method::GET,
false
)
.await
.unwrap()
.is_valid,
"the seeded user must still sign in with its real password"
);
let mut accepted = vec![];
@@ -2593,7 +2815,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
("default/_search", Method::POST),
("default/users", Method::GET),
] {
let res = validate_credentials(email, "", path, &method, false).await;
let res = validate_credentials(email, "", path, None, &method, false).await;
if res.is_ok_and(|r| r.is_valid) {
accepted.push(format!("validate_credentials {email} {method} /{path}"));
}
@@ -2602,6 +2824,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
for email in [sre_agent, plain_user] {
let credentials = base64::encode(&format!("{email}:"));
let req_data = RequestData {
route: None,
uri: "/default/streams".parse().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
@@ -2680,7 +2903,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
("default/user", Method::GET),
] {
assert!(
!validate_credentials(user, pwd, path, &method, false)
!validate_credentials(user, pwd, path, None, &method, false)
.await
.unwrap()
.is_valid,
@@ -2760,19 +2983,33 @@ CN5lTowpVXP2OIiiNTGa75Fj
"root must never acquire lockout state"
);
assert!(
validate_credentials(root_user, root_pwd, "default/_bulk", &Method::POST, false)
.await
.unwrap()
.is_valid,
validate_credentials(
root_user,
root_pwd,
"default/_bulk",
None,
&Method::POST,
false
)
.await
.unwrap()
.is_valid,
"root is never locked out"
);
// Without this the final assertion would also hold for a user who was never created.
assert!(
validate_credentials(locked_user, pwd, "default/_bulk", &Method::POST, false)
.await
.unwrap()
.is_valid,
validate_credentials(
locked_user,
pwd,
"default/_bulk",
None,
&Method::POST,
false
)
.await
.unwrap()
.is_valid,
"the fixture's user must authenticate before being locked out"
);
assert_eq!(
@@ -2838,7 +3075,9 @@ CN5lTowpVXP2OIiiNTGa75Fj
/// mismatch. Panics if the credentials are accepted.
#[cfg(feature = "enterprise")]
async fn refuse(user: &str, password: &str) -> Option<i64> {
match validate_credentials(user, password, "default/_bulk", &Method::POST, false).await {
match validate_credentials(user, password, "default/_bulk", None, &Method::POST, false)
.await
{
Ok(response) => {
assert!(!response.is_valid, "{user} was expected to be refused");
None
@@ -3033,6 +3272,7 @@ CN5lTowpVXP2OIiiNTGa75Fj
#[tokio::test]
async fn an_o2dt_bearer_token_goes_to_the_delegated_validator() {
let req_data = RequestData {
route: None,
uri: "/api/default/streams".parse::<Uri>().unwrap(),
method: Method::GET,
headers: HeaderMap::new(),
+22 -65
View File
@@ -15,7 +15,6 @@
use std::{io::Cursor, sync::Arc};
use arrow::ipc::{CompressionType, writer::IpcWriteOptions};
use arrow_flight::{
Action, ActionType, Criteria, Empty, FlightData, FlightDescriptor, FlightInfo,
HandshakeRequest, HandshakeResponse, PollInfo, PutResult, SchemaResult, Ticket,
@@ -28,11 +27,14 @@ use datafusion::{
common::{DataFusionError, Result},
physical_plan::execute_stream_partitioned,
};
use flight::common::{MetricsInfo, PreCustomMessage};
use flight::common::{MetricsInfo, NodeMessages};
use futures::{StreamExt, stream::BoxStream};
use futures_util::pin_mut;
use prost::Message;
use search::inspector::{SearchInspectorFieldsBuilder, search_inspector_fields};
use search::{
datafusion::distributed_plan::profile,
inspector::{SearchInspectorFieldsBuilder, search_inspector_fields},
};
use search_service::{grpc::flight as grpcFlight, work_group::DeferredLock};
use tonic::{Request, Response, Status, Streaming};
use tracing::Instrument;
@@ -45,11 +47,8 @@ use {
use crate::handler::grpc::{
MetadataMap,
flight::{
stream::{FlightEncoderStreamBuilder, MAX_FLIGHT_DATA_SIZE},
visitor::{
get_cluster_metrics, get_partial_err, get_peak_memory, get_peak_memory_from_ctx,
get_scan_stats,
},
stream::FlightEncoderStreamBuilder,
visitor::{get_peak_memory_from_ctx, node_role},
},
};
@@ -164,31 +163,15 @@ impl FlightService for FlightServiceImpl {
}
let start = std::time::Instant::now();
let write_options: IpcWriteOptions = IpcWriteOptions::default()
.try_with_compression(Some(CompressionType::ZSTD))
.map_err(|e| {
log::error!(
"[trace_id {trace_id}] flight->search: do_get create IPC write options error: {e:?}",
);
Status::internal(e.to_string())
})?;
// used for EXPLAIN ANALYZE to collect metrics after stream is done
let metrics = req.search_info.is_analyze.then_some(MetricsInfo {
plan: plan.clone(),
is_super_cluster,
func: Box::new(super_cluster_enabled),
});
// Get the peak memory usage from the memory pool
// Note: We get peak memory after stream execution, so we pass it via a shared reference
let peak_memory = get_peak_memory_from_ctx(&ctx);
// used for super cluster follower leader to get information from follower node
let scan_stats_ref = get_scan_stats(&plan);
let metrics_ref = get_cluster_metrics(&plan);
let peak_memory_ref = get_peak_memory(&plan);
let partial_err_ref = get_partial_err(&plan);
let node_messages = NodeMessages {
scan_stats,
peak_memory: get_peak_memory_from_ctx(&ctx),
profile: req.search_info.is_analyze.then(|| MetricsInfo {
plan: plan.clone(),
build_profile: profile::build_profile,
}),
role: node_role(&plan),
};
// One stream per output partition so they encode in parallel
let streams = execute_stream_partitioned(plan, ctx.task_ctx()).map_err(|e| {
@@ -198,23 +181,12 @@ impl FlightService for FlightServiceImpl {
Status::internal(e.to_string())
})?;
let mut stream =
FlightEncoderStreamBuilder::new(write_options, MAX_FLIGHT_DATA_SIZE, session_guard)
.with_trace_id(trace_id.to_string())
.with_is_super(is_super_cluster)
.with_defer_lock(lock)
.with_start(start)
.with_custom_message(PreCustomMessage::ScanStats(scan_stats))
.with_custom_message(PreCustomMessage::ScanStatsRef(scan_stats_ref))
.with_custom_message(PreCustomMessage::Metrics(metrics))
.with_custom_message(PreCustomMessage::MetricsRef(metrics_ref))
.with_custom_message(PreCustomMessage::PeakMemoryRef(Some(peak_memory)))
.with_custom_message(PreCustomMessage::PeakMemoryRef(peak_memory_ref))
.with_custom_message(PreCustomMessage::PartialErrRefEarly(
partial_err_ref.clone(),
))
.with_custom_message(PreCustomMessage::PartialErrRef(partial_err_ref))
.build(streams, span);
let mut stream = FlightEncoderStreamBuilder::new(session_guard, node_messages)
.with_trace_id(trace_id.to_string())
.with_is_super(is_super_cluster)
.with_defer_lock(lock)
.with_start(start)
.build(streams, span);
let stream = async_stream::stream! {
let timeout = tokio::time::sleep(tokio::time::Duration::from_secs(timeout));
@@ -367,16 +339,6 @@ fn clear_session_data(trace_id: &str) {
log::info!("Cleared session for trace_id: {trace_id}");
}
#[cfg(feature = "enterprise")]
fn super_cluster_enabled() -> bool {
get_o2_config().super_cluster.enabled
}
#[cfg(not(feature = "enterprise"))]
fn super_cluster_enabled() -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
@@ -385,9 +347,4 @@ mod tests {
fn test_flight_service_impl_default() {
let _impl = FlightServiceImpl;
}
#[test]
fn test_super_cluster_disabled_in_oss() {
assert!(!super_cluster_enabled());
}
}
@@ -23,6 +23,8 @@ use datafusion::execution::SendableRecordBatchStream;
use flight::encoder::{ChunkCoalescer, FlightDataEncoder};
use futures::{Stream, StreamExt};
use super::stream::MAX_FLIGHT_DATA_SIZE;
/// Coalesces and ZSTD-encodes one datafusion output partition into FlightData. do_get runs one per
/// partition on its own tokio task, so partitions execute and encode in parallel.
///
@@ -39,15 +41,11 @@ pub(super) struct PartitionEncoderStream {
}
impl PartitionEncoderStream {
pub(super) fn new(
inner: SendableRecordBatchStream,
options: IpcWriteOptions,
max_flight_data_size: usize,
) -> Self {
pub(super) fn new(inner: SendableRecordBatchStream, options: IpcWriteOptions) -> Self {
Self {
inner,
coalescer: ChunkCoalescer::new(),
encoder: FlightDataEncoder::new(options, max_flight_data_size),
encoder: FlightDataEncoder::new(options, MAX_FLIGHT_DATA_SIZE),
out: VecDeque::new(),
inner_done: false,
}
+92 -91
View File
@@ -15,11 +15,11 @@
use std::{collections::VecDeque, pin::Pin, task::Poll};
use arrow::ipc::writer::IpcWriteOptions;
use arrow::ipc::{CompressionType, writer::IpcWriteOptions};
use arrow_flight::{FlightData, error::FlightError};
use config::metrics;
use datafusion::execution::SendableRecordBatchStream;
use flight::{common::PreCustomMessage, encoder::FlightDataEncoder};
use flight::{common::NodeMessages, encoder::FlightDataEncoder};
use futures::{Stream, StreamExt};
use search_service::work_group::DeferredLock;
use tokio::{sync::mpsc, task::JoinSet};
@@ -30,12 +30,10 @@ use crate::handler::grpc::flight::{SessionGuard, partition_encoder::PartitionEnc
/// Cap on one encoded FlightData message. Mirrors the `ZO_GRPC_MAX_MESSAGE_SIZE` default,
/// which is what the receiving channel accepts.
pub(crate) const MAX_FLIGHT_DATA_SIZE: usize = 32 * 1024 * 1024;
pub(super) const MAX_FLIGHT_DATA_SIZE: usize = 32 * 1024 * 1024;
pub struct FlightEncoderStreamBuilder {
options: IpcWriteOptions,
max_flight_data_size: usize,
custom_messages: Vec<PreCustomMessage>,
node_messages: NodeMessages,
// query context
trace_id: String,
is_super: bool,
@@ -45,15 +43,9 @@ pub struct FlightEncoderStreamBuilder {
}
impl FlightEncoderStreamBuilder {
pub(crate) fn new(
options: IpcWriteOptions,
max_flight_data_size: usize,
session_guard: SessionGuard,
) -> Self {
pub(crate) fn new(session_guard: SessionGuard, node_messages: NodeMessages) -> Self {
Self {
options,
max_flight_data_size,
custom_messages: vec![],
node_messages,
trace_id: String::new(),
is_super: false,
defer_lock: None,
@@ -62,11 +54,6 @@ impl FlightEncoderStreamBuilder {
}
}
pub fn with_custom_message(mut self, message: PreCustomMessage) -> Self {
self.custom_messages.push(message);
self
}
pub fn with_trace_id(mut self, trace_id: String) -> Self {
self.trace_id = trace_id;
self
@@ -98,12 +85,12 @@ impl FlightEncoderStreamBuilder {
) -> FlightEncoderStream {
let child_span = info_span!("grpc:search:flight:execute_physical_plan");
let _ = child_span.set_parent(span.context());
let options = write_options();
let (source, tasks) = if partitions.len() == 1 {
// one partition: no parallelism to gain, poll inline (no task, no channel)
let inner = partitions.into_iter().next().unwrap();
let stream =
PartitionEncoderStream::new(inner, self.options.clone(), self.max_flight_data_size);
let stream = PartitionEncoderStream::new(inner, options.clone());
(FlightSource::Inline(Box::new(stream)), JoinSet::new())
} else {
// one task per partition, merged over the channel
@@ -112,11 +99,7 @@ impl FlightEncoderStreamBuilder {
let (tx, rx) = mpsc::channel::<Result<Vec<FlightData>, FlightError>>(cap);
for inner in partitions {
let tx = tx.clone();
let mut stream = PartitionEncoderStream::new(
inner,
self.options.clone(),
self.max_flight_data_size,
);
let mut stream = PartitionEncoderStream::new(inner, options.clone());
tasks.spawn(async move {
while let Some(item) = stream.next().await {
let is_err = item.is_err();
@@ -134,9 +117,9 @@ impl FlightEncoderStreamBuilder {
FlightEncoderStream {
source,
encoder: FlightDataEncoder::new(self.options, self.max_flight_data_size),
encoder: FlightDataEncoder::new(options, MAX_FLIGHT_DATA_SIZE),
queue: VecDeque::new(),
custom_messages: self.custom_messages,
node_messages: self.node_messages,
first_data: true,
done: false,
ctx: StreamContext {
@@ -167,7 +150,7 @@ pub struct FlightEncoderStream {
/// Only used to encode custom messages (scan stats, peak memory, ...).
encoder: FlightDataEncoder,
queue: VecDeque<FlightData>,
custom_messages: Vec<PreCustomMessage>,
node_messages: NodeMessages,
first_data: bool,
done: bool,
/// query context + resources consumed by the stream's `Drop` teardown.
@@ -232,31 +215,20 @@ impl EventLog {
}
impl FlightEncoderStream {
fn encode_custom_messages(&mut self) -> Result<(), FlightError> {
let custom_messages = std::mem::take(&mut self.custom_messages);
for message in custom_messages.into_iter() {
if let Some(message) = message.get_custom_message() {
let flight_data = self.encoder.encode_custom(&message)?;
self.queue.push_back(flight_data);
}
/// Queues the node's messages due now; a stream with no batch sends its first ones at the end.
fn encode_node_messages(&mut self, at_end: bool) -> Result<(), FlightError> {
let mut messages = Vec::new();
if self.first_data {
messages.extend(self.node_messages.first());
}
Ok(())
}
fn encode_early_emit_messages(&mut self) -> Result<(), FlightError> {
let custom_messages = std::mem::take(&mut self.custom_messages);
let mut remainder_messages = Vec::new();
for message in custom_messages.into_iter() {
if message.is_early_emit() {
if let Some(message) = message.get_custom_message() {
let flight_data = self.encoder.encode_custom(&message)?;
self.queue.push_back(flight_data);
}
} else {
remainder_messages.push(message);
}
if at_end {
messages.extend(self.node_messages.last());
}
self.first_data = false;
for message in messages {
let flight_data = self.encoder.encode_custom(&message)?;
self.queue.push_back(flight_data);
}
self.custom_messages = remainder_messages;
Ok(())
}
@@ -318,14 +290,10 @@ impl Stream for FlightEncoderStream {
// 4. pull the next encoded FlightData from the partition encoder(s)
match this.poll_source(cx) {
Poll::Ready(Some(Ok(group))) => {
// before the first data, emit early-emit custom messages
if this.first_data {
this.first_data = false;
if !this.custom_messages.is_empty()
&& let Err(e) = this.encode_early_emit_messages()
{
return this.fail(e);
}
if this.first_data
&& let Err(e) = this.encode_node_messages(false)
{
return this.fail(e);
}
// enqueue the whole group so the chunk's dictionary and records stay contiguous
this.queue.extend(group);
@@ -345,7 +313,7 @@ impl Stream for FlightEncoderStream {
if !this.ctx.tasks.is_empty() {
return Poll::Pending;
}
if let Err(e) = this.encode_custom_messages() {
if let Err(e) = this.encode_node_messages(true) {
return this.fail(e);
}
this.done = true;
@@ -393,27 +361,30 @@ impl Drop for FlightEncoderStream {
}
}
fn write_options() -> IpcWriteOptions {
IpcWriteOptions::default()
.try_with_compression(Some(CompressionType::ZSTD))
// Cannot fail: compression only needs IPC metadata V5, which is the default.
.expect("default IPC options accept compression")
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::{
array::{Int32Array, RecordBatch},
ipc::writer::IpcWriteOptions,
};
use arrow::array::{Int32Array, RecordBatch};
use arrow_schema::{DataType, Field, Schema};
use config::meta::search::ScanStats;
use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
use flight::common::PreCustomMessage;
use flight::common::{CustomMessage, NodeRole};
use futures::TryStreamExt;
use super::*;
fn make_builder() -> FlightEncoderStreamBuilder {
FlightEncoderStreamBuilder::new(
IpcWriteOptions::default(),
4096,
SessionGuard::new("test-trace".to_string()),
follower_messages(),
)
}
@@ -424,7 +395,6 @@ mod tests {
assert!(!b.is_super);
assert!(b.defer_lock.is_none());
assert!(b.session_guard.is_some());
assert!(b.custom_messages.is_empty());
}
#[test]
@@ -458,11 +428,53 @@ mod tests {
let _ = b.start;
}
#[test]
fn test_builder_with_custom_message() {
let msg = PreCustomMessage::ScanStats(ScanStats::new());
let b = make_builder().with_custom_message(msg);
assert_eq!(b.custom_messages.len(), 1);
fn follower_messages() -> NodeMessages {
NodeMessages {
scan_stats: ScanStats::new(),
peak_memory: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
profile: None,
role: NodeRole::Follower,
}
}
async fn message_kinds(partition: SendableRecordBatchStream) -> Vec<&'static str> {
let fds: Vec<FlightData> = make_builder()
.build(vec![partition], tracing::Span::none())
.try_collect()
.await
.unwrap();
fds.iter()
.map(
|fd| match config::utils::json::from_slice(&fd.app_metadata) {
Ok(CustomMessage::ScanStats(_)) => "ScanStats",
Ok(CustomMessage::PeakMemory(_)) => "PeakMemory",
Ok(_) => "other",
Err(_) => "data",
},
)
.collect()
}
#[tokio::test]
async fn test_node_messages_surround_the_batches() {
let schema = Arc::new(Schema::new(vec![Field::new("v", DataType::Int32, false)]));
let batch =
RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1, 2]))])
.unwrap();
let with_rows: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema.clone(),
futures::stream::iter(vec![Ok(batch)]),
));
let kinds = message_kinds(with_rows).await;
assert_eq!(kinds.first(), Some(&"ScanStats"));
assert_eq!(kinds.last(), Some(&"PeakMemory"));
assert!(kinds[1..kinds.len() - 1].iter().all(|kind| *kind == "data"));
let empty: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new(
schema,
futures::stream::empty(),
));
assert_eq!(message_kinds(empty).await, ["ScanStats", "PeakMemory"]);
}
#[test]
@@ -503,13 +515,9 @@ mod tests {
make_partition(total / 2..total),
];
let options = IpcWriteOptions::default()
.try_with_compression(Some(arrow::ipc::CompressionType::ZSTD))
.unwrap();
let stream = FlightEncoderStreamBuilder::new(
options,
MAX_FLIGHT_DATA_SIZE,
SessionGuard::new("test-trace".to_string()),
follower_messages(),
)
.build(partitions, tracing::Span::none());
@@ -518,6 +526,7 @@ mod tests {
let dicts = std::collections::HashMap::new();
let mut decoded: Vec<i32> = fds
.iter()
.filter(|fd| fd.app_metadata.is_empty())
.flat_map(|fd| {
let b = arrow_flight::utils::flight_data_to_arrow_batch(fd, schema.clone(), &dicts)
.unwrap();
@@ -562,9 +571,8 @@ mod tests {
));
let stream = FlightEncoderStreamBuilder::new(
IpcWriteOptions::default(),
MAX_FLIGHT_DATA_SIZE,
SessionGuard::new("test-trace".to_string()),
follower_messages(),
)
.build(vec![ok_partition, panic_partition], tracing::Span::none());
@@ -676,13 +684,9 @@ mod tests {
let id_base_1 = n_per_partition;
let partitions = vec![make_partition(0, 0), make_partition(1, id_base_1)];
let options = IpcWriteOptions::default()
.try_with_compression(Some(arrow::ipc::CompressionType::ZSTD))
.unwrap();
let stream = FlightEncoderStreamBuilder::new(
options,
MAX_FLIGHT_DATA_SIZE,
SessionGuard::new("test-trace".to_string()),
follower_messages(),
)
.build(partitions, tracing::Span::none());
let fds: Vec<FlightData> = stream.try_collect().await.unwrap();
@@ -749,13 +753,9 @@ mod tests {
futures::stream::iter(batches.into_iter().map(Ok)),
));
let options = IpcWriteOptions::default()
.try_with_compression(Some(arrow::ipc::CompressionType::ZSTD))
.unwrap();
let stream = FlightEncoderStreamBuilder::new(
options,
MAX_FLIGHT_DATA_SIZE,
SessionGuard::new("test-trace".to_string()),
follower_messages(),
)
.build(vec![inner], tracing::Span::none());
@@ -767,6 +767,7 @@ mod tests {
let dicts = std::collections::HashMap::new();
let decoded: Vec<i32> = fds
.iter()
.filter(|fd| fd.app_metadata.is_empty())
.flat_map(|fd| {
let b = arrow_flight::utils::flight_data_to_arrow_batch(fd, schema.clone(), &dicts)
.unwrap();
+19 -142
View File
@@ -13,163 +13,40 @@
// 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/>.
use std::sync::{Arc, atomic::AtomicUsize};
use std::sync::Arc;
use config::meta::search::ScanStats;
use datafusion::{
common::{
Result,
tree_node::{TreeNode, TreeNodeRecursion, TreeNodeVisitor},
},
common::tree_node::{TreeNode, TreeNodeRecursion},
physical_plan::ExecutionPlan,
};
use parking_lot::Mutex;
use flight::common::NodeRole;
use search::datafusion::distributed_plan::remote_scan_exec::RemoteScanExec;
pub use search::datafusion::plan_metrics::{get_cluster_metrics, get_peak_memory_from_ctx};
pub use search::datafusion::plan_metrics::get_peak_memory_from_ctx;
pub fn get_scan_stats(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<Mutex<ScanStats>>> {
let mut visitor = RemoteScanVisitor::new();
let _ = plan.visit(&mut visitor);
visitor.scan_stats
}
struct RemoteScanVisitor {
scan_stats: Option<Arc<Mutex<ScanStats>>>,
}
impl RemoteScanVisitor {
pub fn new() -> Self {
Self { scan_stats: None }
}
}
impl<'n> TreeNodeVisitor<'n> for RemoteScanVisitor {
type Node = Arc<dyn ExecutionPlan>;
fn f_up(&mut self, node: &'n Self::Node) -> Result<TreeNodeRecursion> {
let name = node.name();
if name == "RemoteScanExec" {
let remote_scan_exec = node.downcast_ref::<RemoteScanExec>().unwrap();
self.scan_stats = Some(remote_scan_exec.scan_stats());
Ok(TreeNodeRecursion::Stop)
} else {
Ok(TreeNodeRecursion::Continue)
}
}
}
pub fn get_partial_err(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<Mutex<String>>> {
let mut visitor = PartialErrVisitor::new();
let _ = plan.visit(&mut visitor);
visitor.partial_err
}
struct PartialErrVisitor {
partial_err: Option<Arc<Mutex<String>>>,
}
impl PartialErrVisitor {
pub fn new() -> Self {
Self { partial_err: None }
}
}
impl<'n> TreeNodeVisitor<'n> for PartialErrVisitor {
type Node = Arc<dyn ExecutionPlan>;
fn f_up(&mut self, node: &'n Self::Node) -> Result<TreeNodeRecursion> {
if node.name() == "RemoteScanExec" {
let remote_scan_exec = node.downcast_ref::<RemoteScanExec>().unwrap();
self.partial_err = Some(remote_scan_exec.partial_err());
Ok(TreeNodeRecursion::Stop)
} else {
Ok(TreeNodeRecursion::Continue)
}
}
}
pub fn get_peak_memory(plan: &Arc<dyn ExecutionPlan>) -> Option<Arc<AtomicUsize>> {
let mut visitor = PeakMemoryVisitor::new();
let _ = plan.visit(&mut visitor);
visitor.peak_memory
}
struct PeakMemoryVisitor {
peak_memory: Option<Arc<AtomicUsize>>,
}
impl PeakMemoryVisitor {
pub fn new() -> Self {
Self { peak_memory: None }
}
}
impl<'n> TreeNodeVisitor<'n> for PeakMemoryVisitor {
type Node = Arc<dyn ExecutionPlan>;
fn f_up(&mut self, node: &'n Self::Node) -> Result<TreeNodeRecursion> {
let name = node.name();
if name == "RemoteScanExec" {
let remote_scan_exec = node.downcast_ref::<RemoteScanExec>().unwrap();
self.peak_memory = Some(remote_scan_exec.peak_memory());
}
Ok(TreeNodeRecursion::Continue)
}
/// Only a super cluster's cluster leader runs a plan with a remote scan in it.
pub fn node_role(plan: &Arc<dyn ExecutionPlan>) -> NodeRole {
let mut role = NodeRole::Follower;
let _ = plan.apply(|node| {
let Some(remote_scan) = node.downcast_ref::<RemoteScanExec>() else {
return Ok(TreeNodeRecursion::Continue);
};
role = NodeRole::ClusterLeader(remote_scan.state.clone());
Ok(TreeNodeRecursion::Stop)
});
role
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema};
use datafusion::physical_plan::empty::EmptyExec;
use super::*;
fn empty_plan() -> Arc<dyn datafusion::physical_plan::ExecutionPlan> {
#[test]
fn test_plan_without_remote_scan_is_follower() {
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
Arc::new(EmptyExec::new(schema))
}
#[test]
fn test_remote_scan_visitor_new_has_no_stats() {
let v = RemoteScanVisitor::new();
assert!(v.scan_stats.is_none());
}
#[test]
fn test_partial_err_visitor_new_has_no_err() {
let v = PartialErrVisitor::new();
assert!(v.partial_err.is_none());
}
#[test]
fn test_peak_memory_visitor_new_has_none() {
let v = PeakMemoryVisitor::new();
assert!(v.peak_memory.is_none());
}
#[test]
fn test_get_scan_stats_no_remote_exec_returns_none() {
let plan = empty_plan();
assert!(get_scan_stats(&plan).is_none());
}
#[test]
fn test_get_cluster_metrics_no_remote_exec_returns_empty() {
let plan = empty_plan();
assert!(get_cluster_metrics(&plan).is_empty());
}
#[test]
fn test_get_partial_err_no_remote_exec_returns_none() {
let plan = empty_plan();
assert!(get_partial_err(&plan).is_none());
}
#[test]
fn test_get_peak_memory_no_remote_exec_returns_none() {
let plan = empty_plan();
assert!(get_peak_memory(&plan).is_none());
let plan: Arc<dyn ExecutionPlan> = Arc::new(EmptyExec::new(schema));
assert!(matches!(node_role(&plan), NodeRole::Follower));
}
}
@@ -51,6 +51,7 @@ pub async fn ingest_rejections_middleware(request: Request, next: Next) -> Respo
async fn record_rejections(request: Request, next: Next, sink: &impl RejectionSink) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let route = crate::handler::http::router::matched_route(&request);
let original_uri = request
.extensions()
.get::<OriginalUri>()
@@ -58,7 +59,14 @@ async fn record_rejections(request: Request, next: Next, sink: &impl RejectionSi
let response = next.run(request).await;
if response.status().is_client_error() {
let original_path = original_uri.as_ref().map(|uri| uri.path());
record_if_rejected(sink, &method, uri.path(), original_path, &response);
record_if_rejected(
sink,
&method,
uri.path(),
route.as_deref(),
original_path,
&response,
);
}
response
}
@@ -67,10 +75,11 @@ fn record_if_rejected(
sink: &impl RejectionSink,
method: &Method,
path: &str,
route: Option<&str>,
original_path: Option<&str>,
response: &Response,
) {
let Some(org_id) = rejected_org(method, path, response) else {
let Some(org_id) = rejected_org(method, path, route, response) else {
return;
};
let status = response.status().as_u16();
@@ -94,12 +103,17 @@ fn record_if_rejected(
}
/// The HEC collector names the org in a response extension; API writes carry it in the path.
fn rejected_org(method: &Method, path: &str, response: &Response) -> Option<String> {
fn rejected_org(
method: &Method,
path: &str,
route: Option<&str>,
response: &Response,
) -> Option<String> {
if let Some(org) = response.extensions().get::<RejectionOrg>() {
return Some(org.0.clone());
}
let relative = path.trim_start_matches('/');
if !is_ingestion_write(method, relative) {
if !is_ingestion_write(method, relative, route) {
return None;
}
relative.split('/').next().map(str::to_string)
@@ -443,9 +457,17 @@ mod tests {
#[test]
fn test_a_v2_path_is_recorded_only_for_an_org_named_v2() {
let response = StatusCode::FORBIDDEN.into_response();
let org = |path| rejected_org(&Method::POST, path, &response);
assert_eq!(org("/v2/s/_json").as_deref(), Some("v2"));
assert_eq!(org("/v2/acme/folders/_json"), None);
assert_eq!(org("/acme/s/_json").as_deref(), Some("acme"));
let org = |path, route| rejected_org(&Method::POST, path, Some(route), &response);
let json = "/api/{org_id}/{stream_name}/_json";
assert_eq!(org("/v2/s/_json", json).as_deref(), Some("v2"));
assert_eq!(
org(
"/v2/acme/folders/_json",
"/api/v2/{org_id}/folders/{folder_type}"
),
None
);
assert_eq!(org("/acme/s/_json", json).as_deref(), Some("acme"));
assert_eq!(org("/acme/kv/_json", "/api/{org_id}/kv/{key}"), None);
}
}
+139 -18
View File
@@ -17,7 +17,7 @@ use std::time::Duration;
use axum::{
Router,
extract::{DefaultBodyLimit, FromRequestParts, Path, RawPathParams, Request},
extract::{DefaultBodyLimit, FromRequestParts, MatchedPath, Path, RawPathParams, Request},
http::{Method, StatusCode, Uri, header, request::Parts},
middleware::{self, Next},
response::{IntoResponse, Redirect, Response},
@@ -33,8 +33,8 @@ use openobserve_api_management::request::profiling;
use openobserve_api_management::request::{
alerts, announcements, authz, dashboards, db_monitoring, downtimes, folders, gen_ai_health,
ingest_rejections, kv, metrics_usage, model_pricing, organization, public_dashboards,
query_history, rum_analytics, service_accounts, short_url, slack, slos, sourcemaps, status,
status_pages, stream, synthetics, users,
query_history, rum_analytics, service_accounts, service_streams, short_url, slack, slos,
sourcemaps, status, status_pages, stream, synthetics, users,
};
#[cfg(feature = "cloud")]
use openobserve_api_management::request::{cloud, organization::billing_group};
@@ -67,8 +67,7 @@ use {
openobserve_api_management::request::{
ai, alert_calibration, annotation_queues, annotations, anomaly_detection, datasets,
discovery, domain_management, eval_jobs, experiments, fleet, gen_ai, keys, license, oncall,
playground, prompts, providers, quality, remote_tasks, score_configs, scorers,
service_streams, workflows,
playground, prompts, providers, quality, remote_tasks, score_configs, scorers, workflows,
},
openobserve_api_pipelines::request::re_pattern,
openobserve_api_search::search::patterns,
@@ -326,6 +325,14 @@ fn license_denial(_required: &[Feature]) -> Option<Response> {
None
}
/// The route template axum matched, which a caller cannot shape the way it shapes the path.
pub(crate) fn matched_route(request: &Request) -> Option<String> {
request
.extensions()
.get::<MatchedPath>()
.map(|route| route.as_str().to_owned())
}
/// Authentication middleware for API routes.
///
/// Pair it with `password_policy_middleware` layered inside it on every router that uses it: this
@@ -337,6 +344,7 @@ pub async fn auth_middleware(request: Request, next: Next) -> Response {
uri: request.uri().clone(),
method: request.method().clone(),
headers: request.headers().clone(),
route: matched_route(&request),
};
// Extract auth info from request (synchronous now, no await)
@@ -409,6 +417,7 @@ pub async fn aws_auth_middleware(mut request: Request, next: Next) -> Response {
uri: request.uri().clone(),
method: request.method().clone(),
headers: request.headers().clone(),
route: matched_route(&request),
};
match validator_aws(&req_data).await {
@@ -431,6 +440,7 @@ pub async fn gcp_auth_middleware(mut request: Request, next: Next) -> Response {
uri: request.uri().clone(),
method: request.method().clone(),
headers: request.headers().clone(),
route: matched_route(&request),
};
match validator_gcp(&req_data).await {
@@ -453,6 +463,7 @@ pub async fn rum_auth_middleware(mut request: Request, next: Next) -> Response {
uri: request.uri().clone(),
method: request.method().clone(),
headers: request.headers().clone(),
route: matched_route(&request),
};
match validator_rum(&req_data).await {
@@ -475,6 +486,7 @@ pub async fn proxy_auth_middleware(request: Request, next: Next) -> Response {
uri: request.uri().clone(),
method: request.method().clone(),
headers: request.headers().clone(),
route: matched_route(&request),
};
let (mut parts, body) = request.into_parts();
@@ -702,20 +714,16 @@ pub async fn audit_middleware(request: Request, next: Next) -> Response {
let path = audited_path(request.uri());
let path_columns = path.split('/').collect::<Vec<&str>>();
let ingestion_path = if path.starts_with("api/") {
path.split_once("api/")
.map(|(_, rest)| rest)
.unwrap_or(&path)
} else {
&path
};
let (audit_org, after_org) = audit_org_and_next(&path_columns);
if get_o2_config().common.audit_enabled
&& !(after_org.ends_with("_stream")
|| path.ends_with("ai/chat_stream")
|| common::meta::ingestion_routes::is_ingestion_write(&http_method, ingestion_path))
|| common::meta::ingestion_routes::is_ingestion_write(
&http_method,
&path,
matched_route(&request).as_deref(),
))
{
let query_params = request.uri().query().unwrap_or("").to_string();
let org_id = if audit_org == "organizations" {
@@ -1284,9 +1292,10 @@ pub fn service_routes() -> Router {
#[cfg(not(feature = "enterprise"))]
let server = cfg.common.instance_name_short.to_string();
// Downtimes and Slack live in their own tables to keep this function under the line limit.
// Downtimes, Slack and service streams live in their own tables to keep this function under the line limit.
let mut router =
public_dashboard_admin_routes(Router::new().merge(downtime_routes()).merge(slack_routes()));
router = router.merge(service_streams_routes());
// Full UI configuration — authenticated counterpart of the unauthenticated
// `/config` bootstrap in config_routes()
router = router.route("/{org_id}/config", get(status::zo_config));
@@ -1344,6 +1353,7 @@ pub fn service_routes() -> Router {
// Org info
.route("/{org_id}/summary", get(organization::org::org_summary))
.route("/{org_id}/overview", get(organization::org::org_overview))
.route("/{org_id}/ingest/recent_rejections", get(ingest_rejections::recent_rejections))
.route("/{org_id}/passcode", get(organization::org::get_user_passcode).put(organization::org::update_user_passcode))
.route("/{org_id}/rumtoken", get(organization::org::get_user_rumtoken).post(organization::org::create_user_rumtoken).put(organization::org::update_user_rumtoken))
@@ -1978,10 +1988,7 @@ pub fn service_routes() -> Router {
// Service streams
.route("/{org_id}/service_streams", get(service_streams::list_services))
.route("/{org_id}/service_streams/_analytics", get(service_streams::get_dimension_analytics))
.route("/{org_id}/service_streams/_correlate", post(service_streams::correlate_streams))
.route("/{org_id}/service_streams/config/identity", get(service_streams::get_identity_config).put(service_streams::save_identity_config))
.route("/{org_id}/service_streams/_reset", delete(service_streams::reset_services))
.route("/{org_id}/storage",get(organization::storage::get).post(organization::storage::save).put(organization::storage::update));
router = ai_chat_routes(router);
@@ -2356,6 +2363,15 @@ pub fn service_routes() -> Router {
}))
}
/// Registered in both builds so OSS answers the handlers' 403 enterprise-only stub, not a 404.
#[rustfmt::skip]
fn service_streams_routes() -> Router {
Router::new()
.route("/{org_id}/service_streams/_analytics", get(service_streams::get_dimension_analytics))
.route("/{org_id}/service_streams/_correlate", post(service_streams::correlate_streams))
.route("/{org_id}/service_streams/_reset", delete(service_streams::reset_services))
}
/// Downtimes routes; the literal segments stay before the {downtime_id} catch-all.
#[rustfmt::skip]
pub fn downtime_routes() -> Router {
@@ -3229,6 +3245,42 @@ mod tests {
}
}
#[cfg(not(feature = "enterprise"))]
#[tokio::test]
async fn service_streams_routes_answer_403_not_404_in_the_oss_build() {
let routes = [
(
Method::GET,
"/default/service_streams/_analytics",
Body::empty(),
),
(
Method::POST,
"/default/service_streams/_correlate",
Body::from(
r#"{"source_stream":"s","source_type":"logs","available_dimensions":{}}"#,
),
),
(
Method::DELETE,
"/default/service_streams/_reset",
Body::empty(),
),
];
for (method, uri, body) in routes {
let what = format!("{method} {uri}");
let req = Request::builder()
.method(method)
.uri(uri)
.header("user_id", "root@example.com")
.header("content-type", "application/json")
.body(body)
.unwrap();
let response = service_streams_routes().oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN, "{what}");
}
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn a_bad_body_or_query_is_404_while_slack_is_off() {
@@ -5041,6 +5093,14 @@ mod tests {
.replace(",)", ")")
}
#[test]
fn service_streams_routes_are_merged_unconditionally_in_service_routes() {
assert!(
service_routes_registrations().contains(".merge(service_streams_routes())"),
"service_routes() must merge service_streams_routes() outside the enterprise block"
);
}
#[test]
fn metric_usage_route_is_registered_in_service_routes() {
assert!(
@@ -5574,6 +5634,67 @@ mod tests {
}
}
#[tokio::test]
async fn an_org_ingestion_token_is_judged_by_the_route_each_auth_layer_matched() {
let org = "route_wiring_org";
let token = "o2oi_route_wiring";
common::infra::config::ORGANIZATIONS.write().await.insert(
org.to_string(),
common::meta::organization::Organization {
identifier: org.to_string(),
name: org.to_string(),
org_type: "custom".to_string(),
service_account: None,
},
);
common::infra::config::ORG_INGESTION_TOKENS
.insert(format!("{org}/{token}"), "wiring-token".to_string());
let credentials = config::utils::base64::encode(&format!("collector@example.com:{token}"));
let app = Router::new().nest(
"/o2",
Router::new()
.nest("/api", service_routes())
.merge(other_service_routes()),
);
for (uri, header, accepted) in [
(format!("/o2/api/{org}/_bulk"), "Authorization", true),
(format!("/o2/api/{org}/kv/_json"), "Authorization", false),
(
format!("/o2/api/{org}/streams/_sub"),
"Authorization",
false,
),
(
format!("/o2/aws/{org}/s/_kinesis_firehose"),
"X-Amz-Firehose-Access-Key",
true,
),
(
format!("/o2/gcp/{org}/s/_sub?API-Key={credentials}"),
"",
true,
),
] {
let mut req = Request::builder().method(Method::POST).uri(&uri);
if header == "Authorization" {
req = req.header(header, format!("Basic {credentials}"));
} else if !header.is_empty() {
req = req.header(header, &credentials);
}
let status = app
.clone()
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap()
.status();
assert_eq!(
status != StatusCode::UNAUTHORIZED,
accepted,
"POST {uri}: {status}"
);
}
}
#[tokio::test]
async fn the_public_link_take_over_route_is_mounted_for_post_only() {
let app = public_dashboard_admin_routes(Router::new());
@@ -47,6 +47,7 @@ use crate::{
openobserve_api_management::request::organization::org::rename_org,
openobserve_api_management::request::organization::assume_service_account::assume_service_account,
openobserve_api_management::request::organization::org::org_summary,
openobserve_api_management::request::organization::org::org_overview,
openobserve_api_management::request::organization::org::get_user_passcode,
openobserve_api_management::request::organization::org::update_user_passcode,
openobserve_api_management::request::organization::org::get_user_rumtoken,
@@ -311,6 +312,7 @@ use crate::{
service_streams::correlate_streams,
service_streams::get_identity_config,
service_streams::save_identity_config,
service_streams::reset_services,
gen_ai::clear_agent_registry,
gen_ai::get_agent_mapping,
gen_ai::list_scored_agents,
@@ -1369,7 +1371,6 @@ pub struct ApiDoc;
openobserve_api_management::request::scorers::preview_llm_judge_output_schema,
openobserve_api_management::request::scorers::test_scorer,
openobserve_api_management::request::scorers::update_scorer,
openobserve_api_management::request::service_streams::reset_services,
openobserve_api_management::request::synthetics::agent_setup,
openobserve_api_management::request::users::service_accounts::exchange_token,
openobserve_api_pipelines::request::re_pattern::delete,
@@ -510,8 +510,6 @@ oss route POST /api/{org_id}/search_jobs/{job_id}/cancel
oss route GET /api/{org_id}/search_jobs/{job_id}/result
oss route POST /api/{org_id}/search_jobs/{job_id}/retry
oss route GET /api/{org_id}/service_streams
oss route GET /api/{org_id}/service_streams/_analytics
oss route POST /api/{org_id}/service_streams/_correlate
oss route GET /api/{org_id}/service_streams/config/identity
oss route PUT /api/{org_id}/service_streams/config/identity
oss route GET /api/{org_id}/settings/gen_ai/agent_mapping
@@ -64,6 +64,7 @@ const ROUTER_PREFIXES: &[(&str, &str)] = &[
("oncall_routes", "/api"),
("fleet_router", "/api"),
("slack_routes", "/api"),
("service_streams_routes", "/api"),
("es_compat_routes", "/api"),
("workflow_hook_routes", ""),
];
@@ -28,6 +28,7 @@ use openobserve_api_common::extractors::Headers;
use openobserve_core::{
auth::{UserEmail, check_permissions},
organization::{self, get_passcode, get_rum_token, update_passcode, update_rum_token},
overview,
};
#[cfg(feature = "cloud")]
use {
@@ -384,6 +385,65 @@ pub async fn org_summary(Path(org_id): Path<String>) -> impl IntoResponse {
Json(org_summary)
}
/// GetOrganizationOverview
#[utoipa::path(
get,
path = "/{org_id}/overview",
context_path = "/api",
tag = "Organizations",
operation_id = "GetOrganizationOverview",
summary = "Get organization overview",
description = "Returns the streams, alerts, pipelines and license limits sections the Overview page needs in one call. Each section carries its own status (ok, error, locked or forbidden), so one failing section never fails the response, and results only include objects the caller may see. Responses are cached briefly per caller.",
security(
("Authorization"= [])
),
params(
("org_id" = String, Path, description = "Organization name"),
("top" = Option<u32>, Query, description = "Rows per list (top streams per type, firing alerts). Default 5, clamped to 1..20."),
("refresh" = Option<bool>, Query, description = "Set true (or 1) to skip the short-lived cache and rebuild now."),
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Failure", content_type = "application/json", body = ()),
(status = 403, description = "Forbidden", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Summary", "operation": "get"})),
("x-o2-mcp" = json!({"enabled": false}))
)
)]
pub async fn org_overview(
Path(org_id): Path<String>,
Headers(user_email): Headers<UserEmail>,
Query(query): Query<HashMap<String, String>>,
) -> Response {
let top = match parse_top(&query) {
Ok(top) => top,
Err(message) => return MetaHttpResponse::bad_request(message),
};
let top_n = overview::normalize_top_n(top);
let refresh = parse_refresh(&query);
if !refresh && let Some(hit) = overview::cached_overview(&org_id, &user_email.user_id, top_n) {
return Json(hit).into_response();
}
let scope = match overview::resolve_scope(&org_id, &user_email.user_id).await {
Ok(scope) => scope,
Err(e) => {
log::error!("[overview] failed to resolve permissions for {org_id}: {e}");
return match overview::scope_unavailable(&org_id, &user_email.user_id, top_n).await {
Some(degraded) => Json(degraded).into_response(),
None => MetaHttpResponse::forbidden("Forbidden"),
};
}
};
if scope.denies_everything() {
return MetaHttpResponse::forbidden("Forbidden");
}
let result = overview::get_overview(&org_id, &scope, top_n).await;
Json(result).into_response()
}
/// GetIngestToken
#[utoipa::path(
@@ -2007,14 +2067,58 @@ fn unknown_quota_pool_message(pool: &str) -> String {
format!("unknown quota pool '{pool}' (expected one of: {expected})")
}
fn parse_top(query: &HashMap<String, String>) -> Result<Option<usize>, &'static str> {
match query.get("top").map(|v| v.parse::<usize>()) {
Some(Err(_)) => Err("'top' must be a non-negative integer; values are clamped to 1..20"),
Some(Ok(n)) => Ok(Some(n)),
None => Ok(None),
}
}
fn parse_refresh(query: &HashMap<String, String>) -> bool {
query
.get("refresh")
.is_some_and(|v| v.eq_ignore_ascii_case("true") || v == "1")
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use openobserve_core::overview;
#[cfg(feature = "cloud")]
use openobserve_core::trial_quota::TrialQuotaPool;
#[cfg(feature = "cloud")]
use super::*;
fn q(key: &str, value: &str) -> HashMap<String, String> {
HashMap::from([(key.to_string(), value.to_string())])
}
#[test]
fn parse_top_handles_absent_invalid_and_numeric_values() {
assert_eq!(super::parse_top(&HashMap::new()), Ok(None));
assert!(super::parse_top(&q("top", "abc")).is_err());
assert!(super::parse_top(&q("top", "-1")).is_err());
let top_n = |v: &str| overview::normalize_top_n(super::parse_top(&q("top", v)).unwrap());
assert_eq!(top_n("0"), 1);
assert_eq!(top_n("7"), 7);
assert_eq!(top_n("500"), 20);
assert_eq!(overview::normalize_top_n(None), 5);
}
#[test]
fn parse_refresh_accepts_only_true_or_one() {
for v in ["true", "1", "TRUE"] {
assert!(super::parse_refresh(&q("refresh", v)), "{v}");
}
for v in ["false", "0", "yes", ""] {
assert!(!super::parse_refresh(&q("refresh", v)), "{v}");
}
assert!(!super::parse_refresh(&HashMap::new()));
}
/// The route accepts every key in `ALL_POOLS`, so a hand-written list leaves an admin who
/// typos the pool they want reading a 400 that never names it.
#[cfg(feature = "cloud")]
+237 -148
View File
@@ -29,8 +29,7 @@
//! changing an ingestion endpoint means editing [`INGESTION_ROUTES`] and nothing
//! else.
//!
//! Paths are matched against the request path with the `/api/` (and base-uri)
//! prefix already stripped, so segment 0 is `{org_id}`.
//! Rows match axum's `MatchedPath` below the mount prefix, so segment 0 is `{org_id}`.
use axum::http::Method;
@@ -39,10 +38,7 @@ use axum::http::Method;
enum Seg {
/// A fixed path segment that must match exactly (e.g. `_bulk`, `traces`).
Lit(&'static str),
/// A single path parameter — matches any one non-empty segment
/// (`{org_id}`, `{stream_name}`, `{name}`, ...). It deliberately does NOT
/// match an ingestion keyword by accident because matching is positional:
/// a stream literally named `traces` only ever lands in a `Param` slot.
/// A single route parameter (`{org_id}`, `{stream_name}`, ...); never a literal segment.
Param,
}
@@ -237,70 +233,80 @@ static INGESTION_ROUTES: &[IngestionRoute] = &[
},
];
/// Does this segment pattern list match these path segments?
fn segments_match(patterns: &[Seg], columns: &[&str]) -> bool {
if patterns.len() != columns.len() {
/// Does this segment pattern list match these route-template segments and their path values?
fn segments_match(patterns: &[Seg], columns: &[&str], values: &[&str]) -> bool {
if patterns.len() != columns.len() || columns.len() != values.len() {
return false;
}
patterns.iter().zip(columns).all(|(pat, col)| match pat {
Seg::Lit(lit) => col == lit,
// A param matches any single non-empty segment.
Seg::Param => !col.is_empty(),
})
patterns
.iter()
.zip(columns)
.zip(values)
.all(|((pat, col), value)| match pat {
Seg::Lit(lit) => col == lit,
// matchit lets a param match an empty segment; ingestion needs a value.
Seg::Param => is_route_param(col) && !value.is_empty(),
})
}
/// Classify a request against the ingestion-route table.
///
/// `path` is the request path with the `/api/` and base-uri prefix already
/// stripped, so it starts at `{org_id}` (a leading `/` is tolerated). It is taken as a `&str` —
/// rather than pre-split columns — specifically so the Elasticsearch root-ping route `GET /{org}/`
/// can be told apart from a top-level single-segment collection route such as
/// `GET /organizations`: the former has a trailing slash, the latter does not.
///
/// `method` is the HTTP method. Returns `Some(kind)` only when the request
/// matches a declared ingestion route on both shape and method; `None` for
/// everything else — including data-read routes such as `.../traces/latest`,
/// which are intentionally not in the table.
pub fn classify(method: &Method, path: &str) -> Option<IngestionKind> {
// No ingestion route lives under `/v2`, where segment 0 is not the org.
/// Is this template segment a single-segment axum parameter (`{name}`, not `{*rest}`)?
fn is_route_param(segment: &str) -> bool {
segment
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.is_some_and(|name| !name.is_empty() && !name.starts_with('*'))
}
/// The part of the full `MatchedPath` below the mount prefixes `path` (no leading `/`) has lost.
fn route_below_mount<'a>(route: &'a str, path: &str) -> Option<&'a str> {
let route = route.strip_prefix('/').unwrap_or(route);
let skip = route
.split('/')
.count()
.checked_sub(path.split('/').count())?;
let start = match skip {
0 => 0,
n => route.match_indices('/').nth(n - 1)?.0 + 1,
};
let (mount, below) = route.split_at(start);
// A mount prefix is literal; a param in it means the segments are misaligned.
(!mount.contains('{')).then_some(below)
}
/// Classify by the full `MatchedPath`; `path` only aligns its mount. Absent or misaligned: None.
pub fn classify(method: &Method, path: &str, route: Option<&str>) -> Option<IngestionKind> {
let path = path.strip_prefix('/').unwrap_or(path);
let route = route_below_mount(route?, path)?;
let method = method.as_str();
let columns: Vec<&str> = route.split('/').collect();
let values: Vec<&str> = path.split('/').collect();
// The ES root version ping is the sole ingestion route registered with a
// mandatory trailing slash (`GET /{org}/`). Match it explicitly on the
// trailing slash so it does not collide with unrelated single-segment
// routes (`/organizations`, `/invites`, `/healthz`, ...).
if let Some(org) = path.strip_suffix('/')
&& !org.is_empty()
&& !org.contains('/')
// The ES root ping is the only ingestion route with a mandatory trailing slash (`/{org_id}/`).
if let Some(org) = route.strip_suffix('/')
&& is_route_param(org)
&& values.first().is_some_and(|org| !org.is_empty())
&& (method == "GET" || method == "HEAD")
{
return Some(IngestionKind::EsHandshakeRead);
}
// Everything else matches on exact segment shape. A single trailing empty
// segment (trailing slash) is dropped so `.../_bulk/` == `.../_bulk`.
let mut columns: Vec<&str> = path.split('/').collect();
if columns.len() > 1 && columns.last() == Some(&"") {
columns.pop();
}
INGESTION_ROUTES
.iter()
.find(|route| route.methods.contains(&method) && segments_match(route.segments, &columns))
.find(|route| {
route.methods.contains(&method) && segments_match(route.segments, &columns, &values)
})
.map(|route| route.kind)
}
/// Is this request a data-ingestion **write**? True only for `IngestionKind::Write`.
pub fn is_ingestion_write(method: &Method, path: &str) -> bool {
matches!(classify(method, path), Some(IngestionKind::Write))
pub fn is_ingestion_write(method: &Method, path: &str, route: Option<&str>) -> bool {
matches!(classify(method, path, route), Some(IngestionKind::Write))
}
/// May an org ingestion token be used on this request? True for real writes and
/// the read-only ES handshake stubs — nothing else.
pub fn is_ingestion_allowed(method: &Method, path: &str) -> bool {
classify(method, path).is_some()
pub fn is_ingestion_allowed(method: &Method, path: &str, route: Option<&str>) -> bool {
classify(method, path, route).is_some()
}
/// Is this exactly `{org}/v1/opamp`, the OpAMP route whose auth differs from other writes?
@@ -317,114 +323,205 @@ pub fn is_opamp(path: &str) -> bool {
mod tests {
use super::*;
fn classify_path(method: &Method, path: &str) -> Option<IngestionKind> {
classify(method, path)
/// Classify a route registered under `/api`, requested with every param filled in.
fn classify_route(method: &Method, template: &str) -> Option<IngestionKind> {
let path = template
.split('/')
.map(|s| if is_route_param(s) { "default" } else { s })
.collect::<Vec<_>>()
.join("/");
classify(method, &path, Some(&format!("/api/{template}")))
}
#[test]
fn writes_are_classified() {
for path in [
"default/_bulk",
"default/mystream/_multi",
"default/mystream/_json",
"default/_hec",
"default/loki/api/v1/push",
"default/v1/logs",
"default/v1/metrics",
"default/v1/traces",
"default/v1/profiles",
"default/v1development/profiles",
"default/traces",
"default/otel/v1/traces",
"default/ingest/metrics/_json",
"default/prometheus/api/v1/write",
"default/mystream/_kinesis_firehose",
"default/mystream/_sub",
"v1/default/logs",
for route in [
"{org_id}/_bulk",
"{org_id}/{stream_name}/_multi",
"{org_id}/{stream_name}/_json",
"{org_id}/_hec",
"{org_id}/loki/api/v1/push",
"{org_id}/v1/logs",
"{org_id}/v1/metrics",
"{org_id}/v1/traces",
"{org_id}/v1/profiles",
"{org_id}/v1development/profiles",
"{org_id}/traces",
"{org_id}/otel/v1/traces",
"{org_id}/ingest/metrics/_json",
"{org_id}/prometheus/api/v1/write",
"{org_id}/{stream_name}/_kinesis_firehose",
"{org_id}/{stream_name}/_sub",
"v1/{org_id}/logs",
] {
assert_eq!(
classify_path(&Method::POST, path),
classify_route(&Method::POST, route),
Some(IngestionKind::Write),
"expected Write for POST /{path}"
"expected Write for POST /{route}"
);
}
}
#[test]
fn es_handshake_reads_are_classified() {
for (method, path) in [
(Method::GET, "default/"),
(Method::HEAD, "default/"),
(Method::GET, "default/_license"),
(Method::GET, "default/_xpack"),
(Method::GET, "default/_ilm/policy/logs"),
(Method::HEAD, "default/_index_template/filebeat-7"),
(Method::GET, "default/_data_stream/filebeat-7"),
(Method::GET, "default/_ingest/pipeline/filebeat-7"),
for (method, route) in [
(Method::GET, "{org_id}/"),
(Method::HEAD, "{org_id}/"),
(Method::GET, "{org_id}/_license"),
(Method::GET, "{org_id}/_xpack"),
(Method::GET, "{org_id}/_ilm/policy/{name}"),
(Method::HEAD, "{org_id}/_index_template/{name}"),
(Method::GET, "{org_id}/_data_stream/{name}"),
(Method::GET, "{org_id}/_ingest/pipeline/{name}"),
] {
assert_eq!(
classify_path(&method, path),
classify_route(&method, route),
Some(IngestionKind::EsHandshakeRead),
"expected EsHandshakeRead for {method} /{path}"
"expected EsHandshakeRead for {method} /{route}"
);
}
}
#[test]
fn es_template_creates_are_classified() {
// POST creates on template/data-stream/pipeline are static stubs, with
// arbitrary `{name}` (not necessarily an ingestion word).
for path in [
"default/_index_template/filebeat-7.17.1",
"default/_data_stream/filebeat-7.17.1",
"default/_ingest/pipeline/filebeat-7.17.1",
for route in [
"{org_id}/_index_template/{name}",
"{org_id}/_data_stream/{name}",
"{org_id}/_ingest/pipeline/{name}",
] {
assert_eq!(
classify_path(&Method::POST, path),
classify_route(&Method::POST, route),
Some(IngestionKind::EsHandshakeRead),
"expected EsHandshakeRead for POST /{path}"
"expected EsHandshakeRead for POST /{route}"
);
}
}
#[test]
fn a_management_route_named_after_an_ingestion_literal_is_not_ingestion() {
// The path matches a write row; the matched route does not.
for (route, path) in [
("/api/{org_id}/kv/{key}", "default/kv/_json"),
("/api/{org_id}/kv/{key}", "default/kv/_multi"),
(
"/api/{org_id}/streams/{stream_name}",
"default/streams/_sub",
),
(
"/api/{org_id}/enrichment_tables/{table_name}",
"default/enrichment_tables/_sub",
),
(
"/api/{org_id}/streams/{stream_name}",
"default/streams/_kinesis_firehose",
),
("/api/{org_id}/{module}", "default/_bulk"),
("/api/v1/{org_id}/{module}", "v1/default/logs"),
] {
assert_eq!(
classify(&Method::POST, path, Some(route)),
None,
"POST {route} as /{path}"
);
}
}
#[test]
fn without_a_matched_route_nothing_is_ingestion() {
assert_eq!(classify(&Method::POST, "default/_bulk", None), None);
assert_eq!(classify(&Method::GET, "default/", None), None);
}
#[test]
fn the_mount_prefix_is_lined_up_away() {
for route in [
"/{org_id}/_bulk",
"/api/{org_id}/_bulk",
"/o2/base/api/{org_id}/_bulk",
"/aws/{org_id}/_bulk",
] {
assert_eq!(
classify(&Method::POST, "default/_bulk", Some(route)),
Some(IngestionKind::Write),
"{route}"
);
}
assert_eq!(
classify(&Method::POST, "default/_bulk", Some("/{org_id}/{x}/_bulk")),
None,
"a mount prefix holding a param is misaligned"
);
assert_eq!(
classify(&Method::POST, "a/default/_bulk", Some("/{org_id}/_bulk")),
None,
"a path deeper than the route is misaligned"
);
}
#[test]
fn an_empty_param_value_is_not_ingestion() {
let json = Some("/api/{org_id}/{stream_name}/_json");
assert_eq!(classify(&Method::POST, "default//_json", json), None);
assert_eq!(classify(&Method::POST, "//s/_json", json), None);
assert_eq!(
classify(&Method::GET, "//", Some("/api/{org_id}/")),
None,
"the ES ping needs an org"
);
}
#[test]
fn a_wildcard_is_not_a_param() {
assert_eq!(
classify(&Method::POST, "default/_bulk", Some("/api/{*rest}/_bulk")),
None
);
}
#[test]
fn v2_module_routes_are_not_ingestion() {
for leaf in ["_json", "_multi", "_sub", "_kinesis_firehose"] {
let path = format!("v2/default/folders/{leaf}");
assert_eq!(classify_path(&Method::POST, &path), None, "POST /{path}");
assert_eq!(
classify(
&Method::POST,
&path,
Some("/api/v2/{org_id}/folders/{folder_type}")
),
None,
"POST /{path}"
);
}
assert_eq!(classify_path(&Method::GET, "v2/default/_license"), None);
}
#[test]
fn an_org_named_v2_ingests_like_any_other_org() {
assert_eq!(
classify_path(&Method::POST, "v2/mystream/_json"),
Some(IngestionKind::Write)
);
assert_eq!(
classify_path(&Method::POST, "default/mystream/_json"),
classify(
&Method::POST,
"v2/mystream/_json",
Some("/api/{org_id}/{stream_name}/_json")
),
Some(IngestionKind::Write)
);
}
#[test]
fn data_reads_are_rejected_the_ghsa_bypass() {
// The reported bypass and its blast radius: data-bearing reads must not
// classify as ingestion, for any method.
for path in [
"default/mystream/traces/latest",
"default/mystream/traces/latest_stream",
"default/mystream/traces/session",
"default/mystream/traces/user",
"default/mystream/_values",
"default/mystream/_around",
// Data-bearing reads must not classify as ingestion, for any method.
for route in [
"{org_id}/{stream_name}/traces/latest",
"{org_id}/{stream_name}/traces/latest_stream",
"{org_id}/{stream_name}/traces/session",
"{org_id}/{stream_name}/traces/user",
"{org_id}/{stream_name}/_values",
"{org_id}/{stream_name}/_around",
] {
for method in [Method::GET, Method::HEAD, Method::POST] {
assert_eq!(
classify_path(&method, path),
classify_route(&method, route),
None,
"data read must never classify as ingestion: {method} /{path}"
"data read must never classify as ingestion: {method} /{route}"
);
}
}
@@ -432,8 +529,6 @@ mod tests {
#[test]
fn stream_named_after_es_word_cannot_read_data() {
// A stream literally named `_ingest`/`_ilm`/etc must not let a data read
// slip through as an ES handshake (positional matching prevents it).
for stream in [
"_ingest",
"_ilm",
@@ -444,7 +539,11 @@ mod tests {
] {
let path = format!("default/{stream}/traces/latest");
assert_eq!(
classify(&Method::GET, &path),
classify(
&Method::GET,
&path,
Some("/api/{org_id}/{stream_name}/traces/latest")
),
None,
"stream named {stream} must not alias an ES handshake read"
);
@@ -453,76 +552,66 @@ mod tests {
#[test]
fn wrong_method_is_rejected() {
// Writes are POST-only; ES stubs are GET/HEAD (+POST only for the create
// stubs). PUT/DELETE/PATCH never classify.
for method in [Method::PUT, Method::DELETE, Method::PATCH] {
assert_eq!(classify_path(&method, "default/_bulk"), None);
assert_eq!(classify_path(&method, "default/traces"), None);
assert_eq!(classify_path(&method, "default/_license"), None);
assert_eq!(classify_route(&method, "{org_id}/_bulk"), None);
assert_eq!(classify_route(&method, "{org_id}/traces"), None);
assert_eq!(classify_route(&method, "{org_id}/_license"), None);
}
// A GET to a write endpoint does not classify as a write.
assert_eq!(classify_path(&Method::GET, "default/_bulk"), None);
// A POST to `_license`/`_xpack` (read-only stubs) does not classify.
assert_eq!(classify_path(&Method::POST, "default/_license"), None);
assert_eq!(classify_path(&Method::POST, "default/_xpack"), None);
assert_eq!(classify_route(&Method::GET, "{org_id}/_bulk"), None);
assert_eq!(classify_route(&Method::POST, "{org_id}/_license"), None);
assert_eq!(classify_route(&Method::POST, "{org_id}/_xpack"), None);
}
#[test]
fn non_ingestion_paths_are_rejected() {
assert_eq!(classify_path(&Method::POST, "default/dashboards"), None);
fn non_ingestion_routes_are_rejected() {
assert_eq!(classify_route(&Method::POST, "{org_id}/dashboards"), None);
assert_eq!(
classify_path(&Method::POST, "default/enrichment_tables/mytable"),
classify_route(&Method::POST, "{org_id}/enrichment_tables/{table_name}"),
None
);
assert_eq!(classify_path(&Method::GET, "default/streams"), None);
assert_eq!(classify_path(&Method::DELETE, "default/users/bulk"), None);
assert_eq!(classify_route(&Method::GET, "{org_id}/streams"), None);
assert_eq!(classify_route(&Method::DELETE, "{org_id}/users/bulk"), None);
}
#[test]
fn es_root_ping_requires_trailing_slash() {
// `GET /{org}/` (trailing slash) is the ES version ping — accepted.
assert_eq!(
classify_path(&Method::GET, "default/"),
classify(&Method::GET, "/default/", Some("/api/{org_id}/")),
Some(IngestionKind::EsHandshakeRead)
);
assert_eq!(
classify_path(&Method::HEAD, "default/"),
Some(IngestionKind::EsHandshakeRead)
);
// Also with the leading slash the call sites may pass.
assert_eq!(
classify_path(&Method::GET, "/default/"),
Some(IngestionKind::EsHandshakeRead)
);
// Single-segment top-level collection routes have NO trailing slash and
// must NOT be mistaken for the org ping — otherwise an ingestion-only
// credential would authenticate on them (the over-match the route audit
// caught). These are real routes: `/organizations`, `/invites`,
// `/healthz`, `/clusters`, `/config`.
// Top-level collection routes have no trailing slash and no param.
for literal in ["organizations", "invites", "healthz", "clusters", "config"] {
for method in [Method::GET, Method::HEAD] {
assert_eq!(
classify(&method, literal),
classify(&method, literal, Some(&format!("/api/{literal}"))),
None,
"single-segment route /{literal} must not classify as the ES ping"
);
assert_eq!(
classify(
&method,
&format!("{literal}/"),
Some(&format!("/api/{literal}/"))
),
None,
"a literal route /{literal}/ is not the ES ping"
);
}
}
// A non-GET/HEAD to `/{org}/` is not the ping either.
assert_eq!(classify_path(&Method::POST, "default/"), None);
assert_eq!(classify_route(&Method::GET, "{org_id}"), None);
assert_eq!(classify_route(&Method::POST, "{org_id}/"), None);
}
#[test]
fn opamp_get_and_post_are_ingestion_writes() {
for method in [Method::GET, Method::POST] {
assert_eq!(
classify_path(&method, "default/v1/opamp"),
classify_route(&method, "{org_id}/v1/opamp"),
Some(IngestionKind::Write),
"{method} /default/v1/opamp must be a header-only ingestion write"
"{method} /{{org_id}}/v1/opamp must be a header-only ingestion write"
);
}
assert_eq!(classify_path(&Method::PUT, "default/v1/opamp"), None);
assert_eq!(classify_route(&Method::PUT, "{org_id}/v1/opamp"), None);
}
#[test]
@@ -16,6 +16,7 @@
use std::{
future::Future,
pin::Pin,
sync::LazyLock,
task::{Context, Poll},
time::Instant,
};
@@ -41,6 +42,12 @@ const REDACTED_REQUEST_HEADERS: [&str; 6] = [
/// Workflow hook credentials; a URL value starting with one, in any case or encoding, is cut.
const HOOK_CREDENTIAL_PREFIXES: [&str; 2] = ["o2wh_", "whsec_"];
// Handle generic %{Header}i patterns for request headers
static RE_REQ_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"%\{([^}]+)\}i").unwrap());
// Handle generic %{Header}o patterns for response headers
static RE_RES_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"%\{([^}]+)\}o").unwrap());
/// The access log format: `common`, `json`, or a custom one (%a %t %r %s %b %U %T %D %{Header}i/o).
pub fn get_http_access_log_format() -> String {
let log_format = crate::get_config().http.access_log_format.to_string();
@@ -251,9 +258,7 @@ where
// Generic header patterns are parsed only when the format has one (regex work).
if this.format.contains("}i") {
// Handle generic %{Header}i patterns for request headers
let request_header_regex = Regex::new(r"%\{([^}]+)\}i").unwrap();
for cap in request_header_regex.captures_iter(&this.format.clone()) {
for cap in RE_REQ_HEADER.captures_iter(&this.format.clone()) {
let header_name = &cap[1];
// Skip already-handled headers to avoid redundant work
if header_name == "Content-Length"
@@ -281,9 +286,7 @@ where
}
if this.format.contains("}o") {
// Handle generic %{Header}o patterns for response headers
let response_header_regex = Regex::new(r"%\{([^}]+)\}o").unwrap();
for cap in response_header_regex.captures_iter(&this.format.clone()) {
for cap in RE_RES_HEADER.captures_iter(&this.format.clone()) {
let header_name = &cap[1];
let header_value = response_headers
.iter()
+6
View File
@@ -2869,6 +2869,12 @@ pub struct Limit {
pub req_cols_per_record_limit: usize,
#[env_config(name = "ZO_NODE_HEARTBEAT_TTL", default = 30)] // seconds
pub node_heartbeat_ttl: i64,
#[env_config(
name = "ZO_OVERVIEW_CACHE_TTL",
default = 30,
help = "Seconds the org overview response is cached per caller; 0 disables the cache."
)]
pub overview_cache_ttl: u64,
// How long an o2-ai session->owner claim survives. Must exceed the longest
// expected conversation; mirrors o2-ai's O2_AI_SESSION_OWNER_TTL default.
#[env_config(name = "ZO_AI_SESSION_OWNER_TTL", default = 86400)] // seconds
+9
View File
@@ -40,6 +40,8 @@ pub struct Request {
/// request because the query is planned again in spawned tasks, where
/// that task-local is gone. Never derived from client input.
pub chat_history_reader: bool,
/// Adds the structured `profile` column to EXPLAIN ANALYZE output.
pub analyze_profile: bool,
}
impl Default for Request {
@@ -61,6 +63,7 @@ impl Default for Request {
overwrite_cache: false,
histogram_interval: 0,
chat_history_reader: false,
analyze_profile: false,
}
}
}
@@ -95,6 +98,7 @@ impl Request {
overwrite_cache,
histogram_interval,
chat_history_reader: false,
analyze_profile: false,
}
}
@@ -119,6 +123,10 @@ impl Request {
self.use_index = use_index;
}
pub fn set_analyze_profile(&mut self, analyze_profile: bool) {
self.analyze_profile = analyze_profile;
}
/// Mark the request as the AI Chat API's history reader when — and only
/// when — it is issued from inside that reader's scope.
pub fn mark_chat_history_reader(&mut self) {
@@ -146,6 +154,7 @@ impl From<FlightSearchRequest> for Request {
histogram_interval: req.search_info.histogram_interval,
// Never carried across nodes.
chat_history_reader: false,
analyze_profile: false,
}
}
}
+4
View File
@@ -225,6 +225,9 @@ pub struct Query {
/// that don't carry their own 3rd timezone argument. None / "UTC" / "" => UTC.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timezone: Option<String>,
/// Adds the structured `profile` column to EXPLAIN ANALYZE output.
#[serde(default)]
pub analyze_profile: bool,
}
fn default_size() -> i64 {
@@ -250,6 +253,7 @@ impl Default for Query {
streaming_id: None,
histogram_interval: 0,
timezone: None,
analyze_profile: false,
}
}
}
+48 -3
View File
@@ -14,10 +14,11 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
/// usize indicates the number of parts to skip based on their actual paths.
const QUERIER_ROUTES: [(&str, usize); 47] = [
const QUERIER_ROUTES: [(&str, usize); 48] = [
("config", 0), // /config (unauthenticated bootstrap)
("config", 2), // /api/{org_id}/config (authenticated full)
("summary", 2), // /api/{org_id}/summary
("overview", 2), // /api/{org_id}/overview
("organizations", 1), // /api/organizations
("settings", 2), // /api/{org_id}/settings/...
("schema", 3), // /api/{org_id}/streams/{stream_name}/schema
@@ -67,6 +68,8 @@ const QUERIER_ROUTES: [(&str, usize); 47] = [
("llm/models/built-in", 2), // /api/{org_id}/llm/models/built-in
("llm/models/refresh-built-in", 2), // /api/{org_id}/llm/models/refresh-built-in
];
/// Entries that match their whole remaining path, so `overview_x/...` stays unrouted.
const EXACT_QUERIER_ROUTES: [&str; 1] = ["overview"];
const QUERIER_ROUTES_BY_BODY: [&str; 9] = [
"/_search",
"/_search_partition",
@@ -117,7 +120,12 @@ pub fn is_querier_route(path: &str) -> bool {
}
// Join remaining segments without collecting into a Vec
let route_part = segments.collect::<Vec<_>>().join("/");
route_part.starts_with(route)
let matched = if EXACT_QUERIER_ROUTES.contains(route) {
extract_path_without_query(&route_part) == *route
} else {
route_part.starts_with(route)
};
matched
&& INGESTER_ROUTES
.iter()
.all(|ingest_route| !route_part.ends_with(ingest_route))
@@ -159,7 +167,16 @@ pub fn is_public_dashboard_slug(slug: &str) -> bool {
#[inline]
pub fn is_fixed_querier_route(path: &str) -> bool {
let path = remove_base_uri(path);
FIXED_QUERIER_ROUTES.iter().any(|x| path.contains(x))
FIXED_QUERIER_ROUTES.iter().any(|x| path.contains(x)) || is_org_overview_route(path)
}
/// Only `/api/{org}/overview` itself; a substring match would pin streams named `overview_*`.
fn is_org_overview_route(path: &str) -> bool {
let segments: Vec<&str> = extract_path_without_query(path)
.split('/')
.filter(|s| !s.is_empty())
.collect();
matches!(segments.as_slice(), ["api", _, "overview"])
}
/// The public routes, and the status calls that answer from the querier owning a link's files.
@@ -205,6 +222,14 @@ mod tests {
assert!(is_querier_route("/api/org1/summary"));
assert!(!is_querier_route("/summary")); // Should fail as it needs org_id
assert!(is_querier_route("/api/org1/overview"));
assert!(!is_querier_route("/overview"));
assert!(is_querier_route("/api/org1/overview?top=5"));
assert!(is_querier_route("/api/org1/overview/"));
assert!(!is_querier_route("/api/default/overview_x/_json"));
assert!(!is_querier_route("/api/default/overview_events/_search"));
assert!(!is_querier_route("/api/default/overview/extra"));
// Test streams route
assert!(is_querier_route("/api/org1/streams"));
assert!(is_querier_route("/api/org1/streams/mystream"));
@@ -283,6 +308,7 @@ mod tests {
#[test]
fn test_is_fixed_querier_route() {
assert!(is_fixed_querier_route("/summary"));
assert!(is_fixed_querier_route("/api/org1/overview"));
assert!(is_fixed_querier_route("/schema"));
assert!(is_fixed_querier_route("/streams"));
@@ -290,6 +316,25 @@ mod tests {
assert!(is_fixed_querier_route("/summary_other"));
}
#[test]
fn test_overview_pin_matches_only_the_org_route() {
assert!(is_fixed_querier_route("/api/default/overview"));
assert!(is_fixed_querier_route("/api/default/overview/"));
assert!(is_fixed_querier_route("/api/default/overview?top=5"));
assert!(!is_fixed_querier_route(
"/api/default/overview_events/_search"
));
for path in [
"/api/default/streams/overview/schema",
"/api/default/streams/overview_x/_around",
"/api/default/overview_events/_search",
"/api/default/overview/extra",
"/overview",
] {
assert!(!is_org_overview_route(path), "{path}");
}
}
#[test]
fn test_is_querier_route_ingester_exclusion() {
// streams/_json is a querier-prefix route but ends with an ingester suffix
+1
View File
@@ -153,6 +153,7 @@ infra = { workspace = true, features = ["test-utils"] }
tokio = { workspace = true, features = ["test-util"] }
criterion = { version = "0.5", default-features = false, features = ["rayon"] }
multer.workspace = true
tower.workspace = true
[[bench]]
name = "db_enrich"
+32 -17
View File
@@ -392,12 +392,12 @@ where
return opamp_extractor(&parts.headers, &method, &org_id, &folder);
}
// This is case for ingestion endpoints where we need to check
// permissions on the stream. Classified against the authoritative
// ingestion-route table (method + exact path shape) rather than
// substring-matching an ingestion word, so only real writes take the
// stream-level bypass.
if common::meta::ingestion_routes::is_ingestion_write(&parts.method, path) {
// Only a matched ingestion route takes the stream-level bypass, never a look-alike path.
let route = parts
.extensions
.get::<axum::extract::MatchedPath>()
.map(|route| route.as_str());
if common::meta::ingestion_routes::is_ingestion_write(&parts.method, path, route) {
if let Some(auth_header) = parts.headers.get("Authorization")
&& let Ok(auth_str) = auth_header.to_str()
{
@@ -1022,19 +1022,34 @@ mod tests {
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn ingestion_write_carries_its_route_feature() {
use axum::extract::FromRequestParts;
use axum::{Router, body::Body, routing::post};
use config::meta::feature::Feature;
use tower::ServiceExt;
let (mut parts, _) = axum::http::Request::post("/myorg/v1/logs")
.header("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpwYXNz")
.body(())
.unwrap()
.into_parts();
let Ok(auth) = AuthExtractor::from_request_parts(&mut parts, &()).await else {
panic!("an ingestion write with credentials must extract");
};
assert!(auth.bypass_check);
assert_eq!(auth.feature, Some(&[Feature::Logs][..]));
// `MatchedPath` only exists on a routed request, so the extractor runs behind a router.
let app = Router::new()
.route(
"/{org_id}/v1/logs",
post(|auth: AuthExtractor| async move {
(auth.bypass_check && auth.feature == Some(&[Feature::Logs][..])).to_string()
}),
)
.route(
"/{org_id}/kv/{key}",
post(|auth: AuthExtractor| async move { auth.bypass_check.to_string() }),
);
for (uri, expected) in [("/myorg/v1/logs", "true"), ("/myorg/kv/_json", "false")] {
let request = axum::http::Request::post(uri)
.header("Authorization", "Basic cm9vdEBleGFtcGxlLmNvbTpwYXNz")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), axum::http::StatusCode::OK, "{uri}");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body, expected, "{uri}");
}
}
#[tokio::test]
+1
View File
@@ -57,6 +57,7 @@ pub mod org_cleanup;
#[cfg(feature = "cloud")]
pub mod org_usage;
pub mod organization;
pub mod overview;
pub mod pipeline;
pub mod profiles;
pub mod prompts;
File diff suppressed because it is too large Load Diff
+171 -386
View File
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::{
fmt,
collections::BTreeMap,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
@@ -31,14 +31,15 @@ use config::{
use datafusion::{
self,
physical_plan::{
DisplayFormatType, ExecutionPlan,
display::DisplayableExecutionPlan,
ExecutionPlan,
metrics::{self, ExecutionPlanMetricsSet, MetricBuilder, MetricValue},
},
};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use crate::profile::{ProfileNode, RemoteNodeProfile};
#[derive(Debug, Clone)]
pub struct RemoteScanMetrics {
/// Time in nanos to execute child operator and fetch batches
@@ -115,154 +116,117 @@ pub enum CustomMessage {
#[derive(Default, Debug, Serialize, Deserialize, Clone)]
pub struct Metrics {
/// Unused; kept, like `metrics`, so that older leaders can still parse the message.
pub stage: i32,
pub node_address: String,
pub node_name: String,
/// Left empty: the leader renders the text plan from `profile`.
pub metrics: String,
/// The plan this node ran; older nodes do not send it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ProfileNode>,
}
/// the CustomMessage is not ready, so we need to use this struct to store
/// the scan stats, and send it to the client when the scan stats is ready
pub enum PreCustomMessage {
ScanStats(ScanStats),
// use for super cluster follower leader
ScanStatsRef(Option<Arc<Mutex<ScanStats>>>),
// physical plan, is_super_cluster
Metrics(Option<MetricsInfo>),
// use for super cluster follower leader
MetricsRef(Vec<Arc<Mutex<Vec<Metrics>>>>),
// use for storing memory pool reference to extract peak later
PeakMemoryRef(Option<Arc<AtomicUsize>>),
// sent before first batch (early signal for fast-failing nodes)
PartialErrRefEarly(Option<Arc<Mutex<String>>>),
// sent after all batches (final accumulated value)
PartialErrRef(Option<Arc<Mutex<String>>>),
/// What a node sends besides its record batches; the shared references are read when sent.
pub struct NodeMessages {
/// This node's own scan stats, known before execution.
pub scan_stats: ScanStats,
/// The peak of this node's memory pool.
pub peak_memory: Arc<AtomicUsize>,
/// The plan to send back as a profile; set only for EXPLAIN ANALYZE.
pub profile: Option<MetricsInfo>,
pub role: NodeRole,
}
impl NodeMessages {
/// Sent before the first batch, so the caller sees scan stats and fast failures early.
pub fn first(&self) -> Vec<CustomMessage> {
let mut messages = vec![CustomMessage::ScanStats(self.reported_scan_stats())];
messages.extend(self.partial_err());
messages
}
/// Sent after the last batch.
pub fn last(&self) -> Vec<CustomMessage> {
let mut messages = vec![CustomMessage::PeakMemory(self.reported_peak_memory())];
messages.extend(self.partial_err());
messages.extend(
self.profile
.as_ref()
.map(|info| CustomMessage::Metrics(collect_metrics(info))),
);
messages
}
/// Merged here rather than sent twice: the caller's add gives the same result either way.
fn reported_scan_stats(&self) -> ScanStats {
let mut stats = self.scan_stats;
if let NodeRole::ClusterLeader(remote) = &self.role {
stats.add(&remote.scan_stats.lock());
}
stats
}
fn reported_peak_memory(&self) -> usize {
let own = self.peak_memory.load(Ordering::Relaxed);
match &self.role {
NodeRole::Follower => own,
NodeRole::ClusterLeader(remote) => own.max(remote.peak_memory.load(Ordering::Relaxed)),
}
}
/// Only a cluster leader has one: a follower's own failure ends its stream instead.
fn partial_err(&self) -> Option<CustomMessage> {
let NodeRole::ClusterLeader(remote) = &self.role else {
return None;
};
let err = remote.partial_err.lock().clone();
(!err.is_empty()).then_some(CustomMessage::PartialErr(err))
}
}
pub enum NodeRole {
/// Scans the data itself.
Follower,
/// A super cluster's cluster leader, which passes the query on to its own followers.
ClusterLeader(RemoteScanState),
}
/// Per-partition profiles of one remote scan, keyed by partition.
pub type NodeProfiles = Arc<Mutex<BTreeMap<usize, RemoteNodeProfile>>>;
/// What a remote scan collects from the nodes it calls, shared by every copy of the scan.
#[derive(Debug, Clone, Default)]
pub struct RemoteScanState {
pub scan_stats: Arc<Mutex<ScanStats>>,
pub peak_memory: Arc<AtomicUsize>,
pub partial_err: Arc<Mutex<String>>,
/// Filled only for EXPLAIN ANALYZE.
pub node_profiles: NodeProfiles,
}
pub struct MetricsInfo {
pub plan: Arc<dyn ExecutionPlan>,
pub is_super_cluster: bool,
pub func: Box<dyn Fn() -> bool + Send>,
/// Passed in because it lives in the search crate, which depends on this one.
pub build_profile: fn(&dyn ExecutionPlan) -> ProfileNode,
}
impl PreCustomMessage {
pub fn is_early_emit(&self) -> bool {
matches!(
self,
PreCustomMessage::ScanStats(_)
| PreCustomMessage::ScanStatsRef(_)
| PreCustomMessage::PartialErrRefEarly(_)
)
}
pub fn get_custom_message(&self) -> Option<CustomMessage> {
match self {
PreCustomMessage::ScanStats(stats) => Some(CustomMessage::ScanStats(*stats)),
PreCustomMessage::ScanStatsRef(stats_ref) => stats_ref
.as_ref()
.map(|stats| CustomMessage::ScanStats(*stats.lock())),
PreCustomMessage::Metrics(metrics_ref) => metrics_ref
.as_ref()
.map(|metrics_info| CustomMessage::Metrics(collect_metrics(metrics_info))),
PreCustomMessage::MetricsRef(metrics) => {
let metrics: Vec<Metrics> = metrics.iter().flat_map(|m| m.lock().clone()).collect();
(!metrics.is_empty()).then_some(CustomMessage::Metrics(metrics))
}
PreCustomMessage::PeakMemoryRef(peak_memory_ref) => peak_memory_ref
.as_ref()
.map(|peak| CustomMessage::PeakMemory(peak.load(Ordering::Relaxed))),
PreCustomMessage::PartialErrRefEarly(ref_opt)
| PreCustomMessage::PartialErrRef(ref_opt) => ref_opt.as_ref().and_then(|r| {
let err = r.lock().clone();
if err.is_empty() {
None
} else {
Some(CustomMessage::PartialErr(err))
}
}),
}
}
}
// collect metrics from physical plan
fn collect_metrics(metrics_info: &MetricsInfo) -> Vec<Metrics> {
let plan = &metrics_info.plan;
let is_super_cluster = metrics_info.is_super_cluster;
let func = &metrics_info.func;
let plan_with_metrics = if is_super_cluster {
format_leader_plan(plan.as_ref())
} else {
DisplayableExecutionPlan::with_metrics(plan.as_ref())
.set_show_statistics(false)
.indent(false)
.to_string()
};
let stage = if func() {
if is_super_cluster { 1 } else { 2 }
} else {
1
};
vec![Metrics {
stage,
node_address: LOCAL_NODE.get_grpc_addr().to_string(),
node_name: LOCAL_NODE.get_name().to_string(),
metrics: plan_with_metrics,
profile: Some((metrics_info.build_profile)(metrics_info.plan.as_ref())),
..Default::default()
}]
}
fn format_leader_plan(plan: &dyn ExecutionPlan) -> String {
let mut output = String::new();
write_plan(&mut output, plan, 0);
output
}
struct PlanDisplay<'a>(&'a dyn ExecutionPlan);
impl fmt::Display for PlanDisplay<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt_as(DisplayFormatType::Default, f)
}
}
fn write_plan(output: &mut String, plan: &dyn ExecutionPlan, indent: usize) {
use fmt::Write;
write!(output, "{:indent$}", "", indent = indent * 2).expect("write to String");
write!(output, "{}", PlanDisplay(plan)).expect("write to String");
if let Some(metrics) = plan.metrics() {
let metrics = metrics
.aggregate_by_name()
.sorted_for_display()
.timestamps_removed();
write!(output, ", metrics=[{metrics}]").expect("write to String");
} else {
write!(output, ", metrics=[]").expect("write to String");
}
writeln!(output).expect("write to String");
if plan.name() == "RemoteScanExec" {
return;
}
for child in plan.children() {
write_plan(output, child.as_ref(), indent + 1);
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema};
use config::meta::search::ScanStats;
use datafusion::{
common::Result,
execution::{SendableRecordBatchStream, TaskContext},
physical_plan::{
DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, empty::EmptyExec,
},
};
use datafusion::physical_plan::{ExecutionPlan, empty::EmptyExec};
use parking_lot::Mutex;
use super::*;
@@ -285,70 +249,18 @@ mod tests {
}
}
fn test_profile(plan: &dyn ExecutionPlan) -> ProfileNode {
ProfileNode {
name: plan.name().to_string(),
..Default::default()
}
}
fn create_test_execution_plan() -> Arc<dyn ExecutionPlan> {
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
Arc::new(EmptyExec::new(schema))
}
#[derive(Debug)]
struct NamedExec {
name: &'static str,
inner: Arc<dyn ExecutionPlan>,
}
impl DisplayAs for NamedExec {
fn fmt_as(&self, _t: DisplayFormatType, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "{}", self.name)
}
}
impl ExecutionPlan for NamedExec {
fn apply_expressions(
&self,
_f: &mut dyn FnMut(
&Arc<dyn datafusion::physical_expr::PhysicalExpr>,
) -> datafusion::common::Result<
datafusion::common::tree_node::TreeNodeRecursion,
>,
) -> datafusion::common::Result<datafusion::common::tree_node::TreeNodeRecursion> {
Ok(datafusion::common::tree_node::TreeNodeRecursion::Continue)
}
fn name(&self) -> &'static str {
self.name
}
fn properties(&self) -> &Arc<PlanProperties> {
self.inner.properties()
}
fn children(&self) -> Vec<&Arc<dyn ExecutionPlan>> {
vec![&self.inner]
}
fn with_new_children(
self: Arc<Self>,
_children: Vec<Arc<dyn ExecutionPlan>>,
) -> Result<Arc<dyn ExecutionPlan>> {
unimplemented!()
}
fn execute(
&self,
_partition: usize,
_context: Arc<TaskContext>,
) -> Result<SendableRecordBatchStream> {
unimplemented!()
}
}
fn create_named_test_execution_plan(
name: &'static str,
inner: Arc<dyn ExecutionPlan>,
) -> Arc<dyn ExecutionPlan> {
Arc::new(NamedExec { name, inner })
}
#[test]
fn test_metrics_default() {
let metrics = Metrics::default();
@@ -358,225 +270,97 @@ mod tests {
assert_eq!(metrics.metrics, "");
}
#[test]
fn test_pre_custom_message_is_early_emit() {
let scan_stats = create_test_scan_stats();
// Test ScanStats variant
let pre_msg = PreCustomMessage::ScanStats(scan_stats);
assert!(pre_msg.is_early_emit());
// Test ScanStatsRef variant with Some
let stats_ref = Arc::new(Mutex::new(scan_stats));
let pre_msg = PreCustomMessage::ScanStatsRef(Some(stats_ref));
assert!(pre_msg.is_early_emit());
// Test ScanStatsRef variant with None
let pre_msg = PreCustomMessage::ScanStatsRef(None);
assert!(pre_msg.is_early_emit());
// Test Metrics variant (should be false)
let pre_msg = PreCustomMessage::Metrics(None);
assert!(!pre_msg.is_early_emit());
// Test MetricsRef variant (should be false)
let pre_msg = PreCustomMessage::MetricsRef(vec![]);
assert!(!pre_msg.is_early_emit());
fn describe(messages: Vec<CustomMessage>) -> Vec<String> {
messages
.into_iter()
.map(|message| match message {
CustomMessage::ScanStats(stats) => format!("ScanStats({})", stats.files),
CustomMessage::PeakMemory(peak) => format!("PeakMemory({peak})"),
CustomMessage::PartialErr(err) => format!("PartialErr({err})"),
CustomMessage::Metrics(metrics) => {
format!("Metrics({})", metrics[0].profile.as_ref().unwrap().name)
}
})
.collect()
}
#[test]
fn test_pre_custom_message_get_custom_message_scan_stats() {
let scan_stats = create_test_scan_stats();
let pre_msg = PreCustomMessage::ScanStats(scan_stats);
let custom_msg = pre_msg.get_custom_message().unwrap();
match custom_msg {
CustomMessage::ScanStats(stats) => {
assert_eq!(stats.files, 10);
assert_eq!(stats.records, 1000);
}
_ => panic!("Expected ScanStats variant"),
}
}
#[test]
fn test_pre_custom_message_get_custom_message_scan_stats_ref() {
let scan_stats = create_test_scan_stats();
let stats_ref = Arc::new(Mutex::new(scan_stats));
let pre_msg = PreCustomMessage::ScanStatsRef(Some(stats_ref));
let custom_msg = pre_msg.get_custom_message().unwrap();
match custom_msg {
CustomMessage::ScanStats(stats) => {
assert_eq!(stats.files, 10);
assert_eq!(stats.records, 1000);
}
_ => panic!("Expected ScanStats variant"),
}
}
#[test]
fn test_pre_custom_message_get_custom_message_scan_stats_ref_none() {
let pre_msg = PreCustomMessage::ScanStatsRef(None);
let custom_msg = pre_msg.get_custom_message();
assert!(custom_msg.is_none());
}
#[test]
fn test_pre_custom_message_get_custom_message_metrics() {
let plan = create_test_execution_plan();
let metrics_info = MetricsInfo {
plan,
is_super_cluster: false,
func: Box::new(|| true),
fn test_follower_sends_only_its_own_values() {
let messages = NodeMessages {
scan_stats: create_test_scan_stats(),
peak_memory: Arc::new(AtomicUsize::new(5)),
profile: None,
role: NodeRole::Follower,
};
let pre_msg = PreCustomMessage::Metrics(Some(metrics_info));
let custom_msg = pre_msg.get_custom_message().unwrap();
match custom_msg {
CustomMessage::Metrics(metrics) => {
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].stage, 2); // func() returns true, is_super_cluster is false
assert!(!metrics[0].node_address.is_empty());
assert!(!metrics[0].node_name.is_empty());
assert!(!metrics[0].metrics.is_empty());
}
_ => panic!("Expected Metrics variant"),
}
assert_eq!(describe(messages.first()), ["ScanStats(10)"]);
assert_eq!(describe(messages.last()), ["PeakMemory(5)"]);
}
#[test]
fn test_pre_custom_message_get_custom_message_metrics_none() {
let pre_msg = PreCustomMessage::Metrics(None);
let custom_msg = pre_msg.get_custom_message();
assert!(custom_msg.is_none());
}
#[test]
fn test_pre_custom_message_get_custom_message_metrics_ref() {
let metrics1 = vec![Metrics {
stage: 1,
node_address: "node1".to_string(),
node_name: "Node 1".to_string(),
metrics: "metrics1".to_string(),
}];
let metrics2 = vec![Metrics {
stage: 2,
node_address: "node2".to_string(),
node_name: "Node 2".to_string(),
metrics: "metrics2".to_string(),
}];
let metrics_refs = vec![
Arc::new(Mutex::new(metrics1)),
Arc::new(Mutex::new(metrics2)),
];
let pre_msg = PreCustomMessage::MetricsRef(metrics_refs);
let custom_msg = pre_msg.get_custom_message().unwrap();
match custom_msg {
CustomMessage::Metrics(metrics) => {
assert_eq!(metrics.len(), 2);
assert_eq!(metrics[0].node_address, "node1");
assert_eq!(metrics[1].node_address, "node2");
}
_ => panic!("Expected Metrics variant"),
}
}
#[test]
fn test_pre_custom_message_get_custom_message_metrics_ref_empty() {
let metrics_refs = vec![Arc::new(Mutex::new(vec![]))];
let pre_msg = PreCustomMessage::MetricsRef(metrics_refs);
let custom_msg = pre_msg.get_custom_message();
assert!(custom_msg.is_none());
}
#[test]
fn test_collect_metrics_super_cluster_true() {
let plan = create_test_execution_plan();
let metrics_info = MetricsInfo {
plan,
is_super_cluster: true,
func: Box::new(|| true),
fn test_cluster_leader_merges_what_its_remote_scan_collected() {
let remote = RemoteScanState {
scan_stats: Arc::new(Mutex::new(ScanStats {
files: 3,
..Default::default()
})),
peak_memory: Arc::new(AtomicUsize::new(7)),
..Default::default()
};
let partial_err = remote.partial_err.clone();
let messages = NodeMessages {
scan_stats: create_test_scan_stats(),
peak_memory: Arc::new(AtomicUsize::new(5)),
profile: Some(MetricsInfo {
plan: create_test_execution_plan(),
build_profile: test_profile,
}),
role: NodeRole::ClusterLeader(remote),
};
assert_eq!(describe(messages.first()), ["ScanStats(13)"]);
let metrics = collect_metrics(&metrics_info);
*partial_err.lock() = "node down".to_string();
assert_eq!(
describe(messages.first()),
["ScanStats(13)", "PartialErr(node down)"]
);
assert_eq!(
describe(messages.last()),
[
"PeakMemory(7)",
"PartialErr(node down)",
"Metrics(EmptyExec)"
]
);
}
#[test]
fn test_profile_carries_this_node() {
let metrics = collect_metrics(&MetricsInfo {
plan: create_test_execution_plan(),
build_profile: test_profile,
});
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].stage, 1); // func() returns true, is_super_cluster is true
assert!(!metrics[0].node_address.is_empty());
assert!(!metrics[0].metrics.is_empty());
assert!(!metrics[0].node_name.is_empty());
}
#[test]
fn test_collect_metrics_super_cluster_false() {
let plan = create_test_execution_plan();
let metrics_info = MetricsInfo {
plan,
is_super_cluster: false,
func: Box::new(|| true),
};
let metrics = collect_metrics(&metrics_info);
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].stage, 2); // func() returns true, is_super_cluster is false
fn test_metrics_without_profile_deserializes() {
let json = r#"{"stage":1,"node_address":"a","node_name":"b","metrics":"m"}"#;
let metrics: Metrics = serde_json::from_str(json).unwrap();
assert!(metrics.profile.is_none());
}
#[test]
fn test_collect_metrics_func_returns_false() {
let plan = create_test_execution_plan();
let metrics_info = MetricsInfo {
plan,
is_super_cluster: true,
func: Box::new(|| false),
fn test_metrics_keeps_fields_older_leaders_require() {
let metrics = Metrics {
profile: Some(ProfileNode::default()),
..Default::default()
};
let metrics = collect_metrics(&metrics_info);
assert_eq!(metrics.len(), 1);
assert_eq!(metrics[0].stage, 1); // func() returns false, stage defaults to 1
}
#[test]
fn test_super_cluster_stops_at_remote_scan() {
let follower_plan =
create_named_test_execution_plan("FollowerOnlyExec", create_test_execution_plan());
let remote_scan_plan = create_named_test_execution_plan("RemoteScanExec", follower_plan);
let region_leader_plan =
create_named_test_execution_plan("SortPreservingMergeExec", remote_scan_plan);
let metrics_info = MetricsInfo {
plan: region_leader_plan,
is_super_cluster: true,
func: Box::new(|| true),
};
let metrics = collect_metrics(&metrics_info);
assert_eq!(metrics.len(), 1);
assert!(metrics[0].metrics.contains("SortPreservingMergeExec"));
assert!(metrics[0].metrics.contains("RemoteScanExec"));
assert!(!metrics[0].metrics.contains("FollowerOnlyExec"));
}
#[test]
fn test_non_super_cluster_keeps_remote_scan_child_metrics() {
let follower_plan =
create_named_test_execution_plan("FollowerOnlyExec", create_test_execution_plan());
let remote_scan_plan = create_named_test_execution_plan("RemoteScanExec", follower_plan);
let region_leader_plan =
create_named_test_execution_plan("SortPreservingMergeExec", remote_scan_plan);
let metrics_info = MetricsInfo {
plan: region_leader_plan,
is_super_cluster: false,
func: Box::new(|| true),
};
let metrics = collect_metrics(&metrics_info);
assert_eq!(metrics.len(), 1);
assert!(metrics[0].metrics.contains("FollowerOnlyExec, metrics=[]"));
let json = serde_json::to_value(&metrics).unwrap();
for key in ["stage", "node_address", "node_name", "metrics"] {
assert!(json.get(key).is_some(), "missing {key}");
}
}
#[test]
@@ -603,6 +387,7 @@ mod tests {
node_address: "test_node".to_string(),
node_name: "Test Node".to_string(),
metrics: "test_metrics".to_string(),
profile: None,
}];
let custom_msg = CustomMessage::Metrics(metrics);
+1
View File
@@ -16,3 +16,4 @@
pub mod common;
pub mod decoder;
pub mod encoder;
pub mod profile;
+251
View File
@@ -0,0 +1,251 @@
// 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/>.
//! Structured EXPLAIN ANALYZE profile: the executed plan with typed, per-node metrics.
//!
//! The profile is a tree that follows the leader's physical plan. Ordinary operators
//! recurse through `ProfileInputs::Children`. A `RemoteScanExec` stops the recursion
//! with `ProfileInputs::Remote` instead, because the subtree under it on the leader is
//! only the plan template sent to the remote nodes and never ran there. That list
//! holds one `RemoteNodeProfile` per node it called directly (one per partition),
//! including nodes skipped because they had no files.
//!
//! A `RemoteNodeProfile` has two parts:
//! - what the caller observed of that node: start and end time, rows, batches and bytes received
//! over Flight, decode time, and the node's scan stats;
//! - `plan`: the plan the node actually ran, with its own metrics, sent back by the node itself.
//!
//! A caller lists only the nodes it called directly; deeper nodes nest inside
//! `plan`. In a super cluster the cluster leader's plan has its own
//! `RemoteScanExec`, whose `remote` lists that cluster's queriers and ingesters:
//!
//! ```text
//! QueryProfile super leader
//! └─ plan … RemoteScanExec
//! └─ remote: [sc-a, sc-b, sc-c] cluster leaders
//! └─ plan … RemoteScanExec
//! └─ remote: [querier-0 … ingester-1] nodes of that cluster
//! └─ plan …
//! ```
//!
//! A plan with several remote scans, such as a join of two streams, has one
//! `remote` list per scan, so the same node appears once under each of them.
//!
//! Each plan appears exactly once: a node sends back only its own plan, and its
//! caller stores it under the remote scan that made the call.
//!
//! The leader also renders the text rows of EXPLAIN ANALYZE from this tree, one per
//! node that sent back its plan, which is why `ProfileMetric::display` keeps each
//! value exactly as DataFusion prints it.
use std::fmt::Write;
use config::meta::search::ScanStats;
use serde::{Deserialize, Serialize};
/// The profile of one EXPLAIN ANALYZE run, as returned to the client.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct QueryProfile {
pub node_name: String,
pub node_address: String,
pub start_us: i64,
pub end_us: i64,
pub output_rows: usize,
pub plan: ProfileNode,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileNode {
pub name: String,
pub detail: String,
pub partitions: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metrics: Vec<ProfileMetric>,
#[serde(flatten)]
pub inputs: ProfileInputs,
}
impl ProfileNode {
/// Renders the EXPLAIN ANALYZE text of the plan this node heads, stopping at remote scans.
pub fn to_text(&self) -> String {
let mut out = String::new();
self.write_text(&mut out, 0);
out
}
fn write_text(&self, out: &mut String, indent: usize) {
let metrics = self
.metrics
.iter()
.map(|m| format!("{}={}", m.name, m.display))
.collect::<Vec<_>>()
.join(", ");
writeln!(
out,
"{:indent$}{}, metrics=[{metrics}]",
"",
self.detail,
indent = indent * 2
)
.expect("write to String");
if let ProfileInputs::Children(children) = &self.inputs {
for child in children {
child.write_text(out, indent + 1);
}
}
}
}
/// Where a node's input came from; serialized as a `children` or a `remote` key.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProfileInputs {
/// Operators that ran on this node; empty for a leaf.
Children(Vec<ProfileNode>),
/// Only on a remote scan: one entry per node it called directly.
Remote(Vec<RemoteNodeProfile>),
}
impl Default for ProfileInputs {
fn default() -> Self {
Self::Children(Vec::new())
}
}
/// A metric summed across partitions.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ProfileMetric {
pub name: String,
pub kind: MetricKind,
pub value: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
/// The value as DataFusion prints it, such as `1.20 MB` or `100 total → 20 matched`.
#[serde(default)]
pub display: String,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum MetricKind {
Count,
/// Nanoseconds.
Time,
Bytes,
Gauge,
Ratio,
Pruning,
#[default]
#[serde(other)]
Other,
}
/// What the caller saw of one remote node, plus the plan that node ran.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RemoteNodeProfile {
pub partition: usize,
pub node_name: String,
pub node_address: String,
pub role: String,
pub cluster: String,
pub start_us: i64,
pub end_us: i64,
pub output_rows: u64,
pub output_batches: u64,
pub output_bytes: u64,
pub decode_ns: u64,
pub skipped: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scan_stats: Option<ScanStats>,
/// The plan this node ran; its own remote scans nest the nodes below it the same way.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<ProfileNode>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_json_round_trip_and_unknown_kind() {
let node = ProfileNode {
name: "FilterExec".to_string(),
metrics: vec![ProfileMetric {
name: "output_rows".to_string(),
kind: MetricKind::Count,
value: 1,
total: None,
display: "1".to_string(),
}],
..Default::default()
};
let json = serde_json::to_string(&node).unwrap();
assert!(json.contains(r#""children":[]"#) && !json.contains("remote"));
let back: ProfileNode = serde_json::from_str(&json).unwrap();
assert_eq!(back.metrics, node.metrics);
assert!(matches!(back.inputs, ProfileInputs::Children(c) if c.is_empty()));
let m: ProfileMetric =
serde_json::from_str(r#"{"name":"x","kind":"future_kind","value":1}"#).unwrap();
assert_eq!(m.kind, MetricKind::Other);
}
#[test]
fn test_remote_inputs_serialize_under_remote_key() {
let node = ProfileNode {
name: "RemoteScanExec".to_string(),
inputs: ProfileInputs::Remote(vec![RemoteNodeProfile {
node_name: "q-0".to_string(),
..Default::default()
}]),
..Default::default()
};
let json = serde_json::to_string(&node).unwrap();
assert!(json.contains(r#""remote":[{"#) && !json.contains("children"));
let back: ProfileNode = serde_json::from_str(&json).unwrap();
let ProfileInputs::Remote(remote) = back.inputs else {
panic!("expected remote inputs");
};
assert_eq!(remote[0].node_name, "q-0");
}
#[test]
fn test_to_text_indents_children_and_stops_at_remote() {
let metric = |name: &str, display: &str| ProfileMetric {
name: name.to_string(),
display: display.to_string(),
..Default::default()
};
let node = ProfileNode {
detail: "SortExec: expr=[a@0 ASC]".to_string(),
metrics: vec![metric("output_rows", "3"), metric("spilled_bytes", "0.0 B")],
inputs: ProfileInputs::Children(vec![ProfileNode {
detail: "RemoteScanExec: partitions=2".to_string(),
inputs: ProfileInputs::Remote(vec![RemoteNodeProfile {
plan: Some(ProfileNode {
detail: "FilterExec".to_string(),
..Default::default()
}),
..Default::default()
}]),
..Default::default()
}]),
..Default::default()
};
assert_eq!(
node.to_text(),
"SortExec: expr=[a@0 ASC], metrics=[output_rows=3, spilled_bytes=0.0 B]\n RemoteScanExec: partitions=2, metrics=[]\n"
);
}
}
+63
View File
@@ -241,6 +241,27 @@ pub async fn get_from_db(
})
}
/// Sums the versions created at or after `since` across `streams`, excluding each stream's oldest
/// (its creation), under one cache read lock.
pub async fn count_cached_versions_since(
org_id: &str,
streams: &[(StreamType, &str)],
since: i64,
) -> usize {
let r = STREAM_SCHEMAS.read().await;
streams
.iter()
.map(|(stream_type, stream_name)| {
let cache_key = format!("{org_id}/{stream_type}/{stream_name}");
r.get(&cache_key).map_or(0, |versions| {
let oldest = versions.iter().map(|(t, _)| *t).min();
let in_window = versions.iter().filter(|(t, _)| *t >= since).count();
in_window - usize::from(oldest.is_some_and(|t| t >= since))
})
})
.sum()
}
#[tracing::instrument(name = "infra:schema:get_versions", skip_all)]
pub async fn get_versions(
org_id: &str,
@@ -1142,6 +1163,48 @@ fn user_data_org(key: &str) -> Option<&str> {
mod tests {
use super::*;
#[tokio::test]
async fn test_count_cached_versions_since_skips_creation_and_includes_boundary() {
let schema = Schema::empty();
let versions = vec![
(100, schema.clone()),
(200, schema.clone()),
(300, schema.clone()),
];
{
let mut w = STREAM_SCHEMAS.write().await;
w.insert("count_versions_org/logs/three".to_string(), versions);
w.insert(
"count_versions_org/logs/one".to_string(),
vec![(300, schema)],
);
}
let count = |name: &'static str, since: i64| async move {
count_cached_versions_since("count_versions_org", &[(StreamType::Logs, name)], since)
.await
};
assert_eq!(count("three", 200).await, 2);
assert_eq!(count("three", 201).await, 1);
assert_eq!(count("three", 0).await, 2);
assert_eq!(count("one", 0).await, 0);
assert_eq!(count("one", 300).await, 0);
assert_eq!(count("missing", 0).await, 0);
let all = [
(StreamType::Logs, "three"),
(StreamType::Logs, "one"),
(StreamType::Logs, "missing"),
];
assert_eq!(
count_cached_versions_since("count_versions_org", &all, 0).await,
2
);
assert_eq!(
count_cached_versions_since("count_versions_org", &[], 0).await,
0
);
}
#[tokio::test]
async fn test_stream_settings_mutation_api() {
// unique org prefix so the shared global caches can host parallel tests
+108 -3
View File
@@ -30,9 +30,10 @@ use config::meta::{
use hashbrown::HashMap;
use itertools::Itertools;
use sea_orm::{
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DeriveIden, EntityTrait, ModelTrait,
Order, PaginatorTrait, QueryFilter, QueryOrder, Set, TransactionTrait, TryIntoModel,
prelude::Expr, sea_query::Func,
ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, DeriveIden, EntityTrait,
FromQueryResult, JoinType, ModelTrait, Order, PaginatorTrait, QueryFilter, QueryOrder,
QuerySelect, RelationTrait, Set, TransactionTrait, TryIntoModel, prelude::Expr,
sea_query::Func,
};
use svix_ksuid::{Ksuid, KsuidLike};
@@ -47,6 +48,17 @@ use crate::{
pub mod intermediate;
/// The alert columns a summary needs, read without parsing the stored alert body.
#[derive(Clone, Debug, FromQueryResult)]
pub struct AlertSummaryRow {
pub id: String,
pub name: String,
/// The folder's public id, not its surrogate key.
pub folder_id: String,
pub stream_name: String,
pub enabled: bool,
}
impl TryFrom<alerts::Model> for MetaAlert {
type Error = errors::Error;
@@ -467,6 +479,28 @@ pub async fn list<C: ConnectionTrait>(
Ok(alerts)
}
/// Lists every alert in the org as [`AlertSummaryRow`], selecting only the columns it needs.
pub async fn list_summary_rows<C: ConnectionTrait>(
conn: &C,
org_id: &str,
) -> Result<Vec<AlertSummaryRow>, errors::Error> {
Ok(alerts::Entity::find()
.select_only()
.columns([
alerts::Column::Id,
alerts::Column::Name,
alerts::Column::StreamName,
alerts::Column::Enabled,
])
.column_as(folders::Column::FolderId, "folder_id")
.join(JoinType::InnerJoin, alerts::Relation::Folders.def())
.filter(folders::Column::Type.eq::<i16>(folder_type_into_i16(FolderType::Alerts)))
.filter(folders::Column::Org.eq(org_id))
.into_model::<AlertSummaryRow>()
.all(conn)
.await?)
}
/// The `(long, short)` burn-window pairs of every alert pointing at this SLO.
///
/// The reverse lookup D60 put the indexed `slo_id` column there for: it runs
@@ -1869,6 +1903,77 @@ pub(super) mod tests {
assert_eq!(ids_of(&db, only_enabled).await, vec![on]);
}
#[tokio::test]
async fn list_summary_rows_returns_public_folder_id_and_enabled_flag() {
let db = db_with_folder().await;
seed_folder(&db, FOLDER_B_PK, ORG, "ops").await;
let (on, off) = (alert_id(), alert_id());
insert_alert(&db, AlertSpec::plain(&on, "on")).await;
insert_alert(
&db,
AlertSpec::plain(&off, "off")
.disabled()
.in_folder(ORG, FOLDER_B_PK),
)
.await;
let mut rows = super::super::list_summary_rows(&db, ORG).await.unwrap();
rows.sort_by(|a, b| a.name.cmp(&b.name));
let got: Vec<(&str, &str, &str, bool)> = rows
.iter()
.map(|r| {
(
r.id.as_str(),
r.name.as_str(),
r.folder_id.as_str(),
r.enabled,
)
})
.collect();
assert_eq!(
got,
vec![
(off.as_str(), "alert-off", "ops", false),
(on.as_str(), "alert-on", "default", true),
]
);
assert_eq!(rows[0].stream_name, "default");
}
#[tokio::test]
async fn list_summary_rows_excludes_other_orgs_and_non_alert_folders() {
let db = db_with_folder().await;
seed_folder(&db, FOLDER_OTHER_ORG_PK, OTHER_ORG, "default").await;
folders::Entity::insert(folders::ActiveModel {
id: Set("folder_pk_00000000000000009".to_string()),
org: Set(ORG.to_string()),
folder_id: Set("dash".to_string()),
name: Set("folder-dash".to_string()),
description: Set(None),
r#type: Set(folder_type_into_i16(FolderType::Dashboards)),
icon: Set(None),
})
.exec(&db)
.await
.unwrap();
let (mine, theirs, dash) = (alert_id(), alert_id(), alert_id());
insert_alert(&db, AlertSpec::plain(&mine, "mine")).await;
insert_alert(
&db,
AlertSpec::plain(&theirs, "theirs").in_folder(OTHER_ORG, FOLDER_OTHER_ORG_PK),
)
.await;
insert_alert(
&db,
AlertSpec::plain(&dash, "dash").in_folder(ORG, "folder_pk_00000000000000009"),
)
.await;
let rows = super::super::list_summary_rows(&db, ORG).await.unwrap();
let ids: Vec<&str> = rows.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec![mine.as_str()]);
}
/// No filter = historical behaviour, including non-SLO alerts.
#[tokio::test]
async fn list_models_without_slo_filter_returns_everything() {
+9 -2
View File
@@ -265,7 +265,14 @@ async fn warn_about(
}
};
let rendered = notify::render_coverage_gap_ahead(&gap.team.name, gap.at, gap.hours_away(now));
let schedule_url =
notify::team_schedule_url(&config::get_config().common.web_url, org_id, &gap.team.id);
let rendered = notify::render_coverage_gap_ahead(
&gap.team.name,
gap.at,
gap.hours_away(now),
&schedule_url,
);
for recipient in audience {
let addressed = notify::Addressed {
page: notify::Page {
@@ -275,7 +282,7 @@ async fn warn_about(
title: "coverage gap".to_string(),
priority: config::meta::alerts::priority::AlertPriority::P3,
reason: "you are on this team, or an admin of this org".to_string(),
detail_url: String::new(),
detail_url: schedule_url.clone(),
// No alert behind this one, so no runbook to follow.
runbook_url: None,
investigation: vec![],
@@ -22,9 +22,10 @@ use datafusion::{
common::Result, execution::SendableRecordBatchStream,
physical_plan::stream::RecordBatchStreamAdapter,
};
use flight::common::Metrics;
use parking_lot::Mutex;
use super::profile::NodeProfileRecorder;
#[derive(Debug)]
pub struct QueryContext {
pub trace_id: String,
@@ -33,13 +34,13 @@ pub struct QueryContext {
pub is_querier: bool,
pub scan_stats: Arc<Mutex<ScanStats>>,
pub partial_err: Arc<Mutex<String>>,
pub cluster_metrics: Arc<Mutex<Vec<Metrics>>>,
pub peak_memory: Arc<AtomicUsize>,
pub start: std::time::Instant,
pub num_rows: usize,
pub req_id: u64,
pub req_last_time: std::time::Instant,
pub print_key_event: bool,
pub node_profile: NodeProfileRecorder,
}
impl QueryContext {
@@ -50,7 +51,6 @@ impl QueryContext {
is_querier: false,
scan_stats: Arc::new(Mutex::new(ScanStats::new())),
partial_err: Arc::new(Mutex::new(String::new())),
cluster_metrics: Arc::new(Mutex::new(Vec::new())),
peak_memory: Arc::new(AtomicUsize::new(0)),
trace_id: String::new(),
start: std::time::Instant::now(),
@@ -58,6 +58,7 @@ impl QueryContext {
req_id: 0,
req_last_time: std::time::Instant::now(),
print_key_event: config::get_config().common.print_key_event,
node_profile: NodeProfileRecorder::default(),
}
}
@@ -86,11 +87,6 @@ impl QueryContext {
self
}
pub fn with_cluster_metrics(mut self, metrics: Arc<Mutex<Vec<Metrics>>>) -> Self {
self.cluster_metrics = metrics;
self
}
pub fn with_start_time(mut self, start: std::time::Instant) -> Self {
self.start = start;
self
@@ -100,6 +96,11 @@ impl QueryContext {
self.peak_memory = peak_memory;
self
}
pub fn with_node_profile(mut self, node_profile: NodeProfileRecorder) -> Self {
self.node_profile = node_profile;
self
}
}
/// EmptyStream is a stream that returns an empty record batch
@@ -67,7 +67,7 @@ impl FlightDecoderStream {
self.query_context.scan_stats.lock().add(&stats);
}
CustomMessage::Metrics(metrics) => {
self.query_context.cluster_metrics.lock().extend(metrics);
self.query_context.node_profile.record_plan(metrics);
}
CustomMessage::PeakMemory(peak_memory) => {
self.query_context
@@ -191,6 +191,10 @@ impl Stream for FlightDecoderStream {
impl Drop for FlightDecoderStream {
fn drop(&mut self) {
let scan_stats = self.scan_stats;
self.query_context
.node_profile
.finish(&self.metrics, scan_stats);
let QueryContext {
trace_id,
node,
@@ -206,7 +210,6 @@ impl Drop for FlightDecoderStream {
} else {
"follower".to_string()
};
let scan_stats = self.scan_stats;
let batches = self.metrics.batches_received.value();
let bytes = self.metrics.bytes_received.value();
let avg_ms = self.metrics.avg_batch_time_ms();
@@ -16,12 +16,12 @@
use std::sync::Arc;
use arrow::{
array::{Int32Builder, StringBuilder},
array::{ArrayBuilder, ArrayRef, Int32Builder, StringArray, StringBuilder},
datatypes::SchemaRef,
record_batch::RecordBatch,
};
use arrow_schema::{DataType, Field, Schema};
use config::{cluster::LOCAL_NODE, meta::cluster::NodeInfo};
use config::{cluster::LOCAL_NODE, meta::cluster::NodeInfo, utils::time::now_micros};
use datafusion::{
common::{DataFusionError, Result, internal_err, tree_node::TreeNodeRecursion},
execution::TaskContext,
@@ -32,24 +32,22 @@ use datafusion::{
stream::RecordBatchStreamAdapter,
},
};
use flight::common::Metrics;
use flight::profile::{ProfileInputs, ProfileNode, QueryProfile, RemoteNodeProfile};
use futures::StreamExt;
use crate::datafusion::{
distributed_plan::display::DisplayableExecutionPlan, plan_metrics::get_cluster_metrics,
use crate::datafusion::distributed_plan::{
display::DisplayableExecutionPlan, profile::build_profile,
};
/// query EXPLAIN ANALYZE in distributed mode
/// the output recordbatch's schema is
/// phase: current we have three phase
/// node: the ip or partition id
/// plan: plan with metrics
#[derive(Debug)]
/// EXPLAIN ANALYZE across nodes: one text row per node that ran a plan, phase = its depth.
#[derive(Debug, Clone)]
pub struct DistributeAnalyzeExec {
/// Control how much extra to print
verbose: bool,
/// If statistics should be displayed
show_statistics: bool,
/// Adds the `profile` column, the whole profile as JSON on the first row.
profile: bool,
/// The input plan (the plan being analyzed)
pub input: Arc<dyn ExecutionPlan>,
/// The output schema for RecordBatches of this exec node
@@ -58,25 +56,90 @@ pub struct DistributeAnalyzeExec {
}
impl DistributeAnalyzeExec {
/// Create a new AnalyzeExec
pub fn new(verbose: bool, show_statistics: bool, input: Arc<dyn ExecutionPlan>) -> Self {
let fields = vec![
pub fn new(
verbose: bool,
show_statistics: bool,
profile: bool,
input: Arc<dyn ExecutionPlan>,
) -> Self {
let mut fields = vec![
Field::new("phase", DataType::Int32, false),
Field::new("node_address", DataType::Utf8, false),
Field::new("node_name", DataType::Utf8, false),
Field::new("plan", DataType::Utf8, false),
];
if profile {
fields.push(Field::new("profile", DataType::Utf8, true));
}
let schema = Arc::new(Schema::new(fields));
let cache = Self::compute_properties(&input, Arc::clone(&schema));
DistributeAnalyzeExec {
verbose,
show_statistics,
profile,
input,
schema,
cache,
}
}
fn output_batch(
&self,
profile: &QueryProfile,
duration: std::time::Duration,
) -> Result<RecordBatch> {
let mut phase_builder = Int32Builder::new();
let mut address_builder = StringBuilder::with_capacity(1, 1024);
let mut name_builder = StringBuilder::with_capacity(1, 1024);
let mut plan_builder = StringBuilder::with_capacity(1, 1024);
phase_builder.append_value(0);
address_builder.append_value(&profile.node_address);
name_builder.append_value(&profile.node_name);
let annotated_plan = DisplayableExecutionPlan::new(self.input.as_ref())
.indent(self.verbose)
.to_string();
plan_builder.append_value(annotated_plan);
let mut remote_rows = Vec::new();
collect_remote_rows(&profile.plan, 1, &mut remote_rows);
for (stage, node, plan) in remote_rows {
phase_builder.append_value(stage);
address_builder.append_value(&node.node_address);
name_builder.append_value(&node.node_name);
plan_builder.append_value(plan.to_text());
}
if self.verbose {
phase_builder.append_value(0);
address_builder.append_value("Output Rows");
name_builder.append_value("");
plan_builder.append_value(profile.output_rows.to_string());
phase_builder.append_value(0);
address_builder.append_value("Duration");
name_builder.append_value("");
plan_builder.append_value(format!("{duration:?}"));
}
let rows = phase_builder.len();
let mut columns: Vec<ArrayRef> = vec![
Arc::new(phase_builder.finish()),
Arc::new(address_builder.finish()),
Arc::new(name_builder.finish()),
Arc::new(plan_builder.finish()),
];
if self.profile {
let json =
serde_json::to_string(profile).map_err(|e| DataFusionError::External(e.into()))?;
columns.push(Arc::new(StringArray::from_iter(
std::iter::once(Some(json)).chain(std::iter::repeat_n(None, rows - 1)),
)));
}
RecordBatch::try_new(Arc::clone(&self.schema), columns).map_err(DataFusionError::from)
}
/// This function creates the cache object that stores the plan properties such as schema,
/// equivalence properties, ordering, partitioning, etc.
fn compute_properties(
@@ -130,6 +193,7 @@ impl ExecutionPlan for DistributeAnalyzeExec {
Ok(Arc::new(Self::new(
self.verbose,
self.show_statistics,
self.profile,
children.pop().unwrap(),
)))
}
@@ -147,10 +211,8 @@ impl ExecutionPlan for DistributeAnalyzeExec {
// Create future that computes the final output
let start = std::time::Instant::now();
let captured_input = Arc::clone(&self.input);
let captured_schema = Arc::clone(&self.schema);
let verbose = self.verbose;
let show_statistics = self.show_statistics;
let start_us = now_micros();
let exec = self.clone();
let mut stream = execute_stream(self.input.clone(), context.clone())?;
let output = async move {
@@ -158,16 +220,19 @@ impl ExecutionPlan for DistributeAnalyzeExec {
while let Some(batch) = stream.next().await.transpose()? {
total_rows += batch.num_rows();
}
// remote nodes publish their profiles when their streams are dropped
drop(stream);
let duration = std::time::Instant::now() - start;
create_output_batch(
verbose,
show_statistics,
total_rows,
duration,
captured_input,
captured_schema,
)
let profile = QueryProfile {
node_name: LOCAL_NODE.get_name(),
node_address: LOCAL_NODE.get_grpc_addr(),
start_us,
end_us: now_micros(),
output_rows: total_rows,
plan: build_profile(exec.input.as_ref()),
};
exec.output_batch(&profile, duration)
};
Ok(Box::pin(RecordBatchStreamAdapter::new(
@@ -177,75 +242,35 @@ impl ExecutionPlan for DistributeAnalyzeExec {
}
}
fn create_output_batch(
verbose: bool,
_show_statistics: bool,
total_rows: usize,
duration: std::time::Duration,
input: Arc<dyn ExecutionPlan>,
schema: SchemaRef,
) -> Result<RecordBatch> {
let mut phase_builder = Int32Builder::new();
let mut address_builder = StringBuilder::with_capacity(1, 1024);
let mut name_builder = StringBuilder::with_capacity(1, 1024);
let mut plan_builder = StringBuilder::with_capacity(1, 1024);
phase_builder.append_value(0); // Phase 0 for the main plan
address_builder.append_value(LOCAL_NODE.get_grpc_addr());
name_builder.append_value(LOCAL_NODE.get_name());
let annotated_plan = DisplayableExecutionPlan::new(input.as_ref())
.indent(verbose)
.to_string();
plan_builder.append_value(annotated_plan);
// add metrics from remote scan
let metrics = collect_metrics(input);
for m in metrics {
phase_builder.append_value(m.stage);
address_builder.append_value(m.node_address);
name_builder.append_value(m.node_name);
plan_builder.append_value(m.metrics);
/// Lists, depth first, each remote node that sent back its plan, with its stage below the leader.
fn collect_remote_rows<'a>(
node: &'a ProfileNode,
stage: i32,
rows: &mut Vec<(i32, &'a RemoteNodeProfile, &'a ProfileNode)>,
) {
match &node.inputs {
ProfileInputs::Children(children) => {
for child in children {
collect_remote_rows(child, stage, rows);
}
}
ProfileInputs::Remote(remotes) => {
for (remote, plan) in remotes
.iter()
.filter_map(|r| r.plan.as_ref().map(|plan| (r, plan)))
{
rows.push((stage, remote, plan));
collect_remote_rows(plan, stage + 1, rows);
}
}
}
if verbose {
phase_builder.append_value(0); // Phase 0 for the main plan
address_builder.append_value("Output Rows");
name_builder.append_value("");
plan_builder.append_value(total_rows.to_string());
phase_builder.append_value(0); // Phase 0 for the main plan
address_builder.append_value("Duration");
name_builder.append_value("");
plan_builder.append_value(format!("{duration:?}"));
}
RecordBatch::try_new(
schema,
vec![
Arc::new(phase_builder.finish()),
Arc::new(address_builder.finish()),
Arc::new(name_builder.finish()),
Arc::new(plan_builder.finish()),
],
)
.map_err(DataFusionError::from)
}
fn collect_metrics(plan: Arc<dyn ExecutionPlan>) -> Vec<Metrics> {
let mut metrics = Vec::new();
for m in get_cluster_metrics(&plan) {
let m = m.lock().clone();
metrics.extend(m);
}
metrics
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::array::Array;
use arrow_schema::{DataType, Field, Schema};
use datafusion::physical_plan::empty::EmptyExec;
@@ -259,7 +284,7 @@ mod tests {
#[test]
fn test_distribute_analyze_exec_new_non_verbose() {
let input = empty_input();
let exec = DistributeAnalyzeExec::new(false, false, input);
let exec = DistributeAnalyzeExec::new(false, false, false, input);
assert_eq!(exec.name(), "DistributeAnalyzeExec");
assert_eq!(exec.children().len(), 1);
}
@@ -267,16 +292,91 @@ mod tests {
#[test]
fn test_distribute_analyze_exec_new_verbose() {
let input = empty_input();
let exec = DistributeAnalyzeExec::new(true, true, input);
let exec = DistributeAnalyzeExec::new(true, true, false, input);
assert_eq!(exec.name(), "DistributeAnalyzeExec");
}
#[test]
fn test_distribute_analyze_exec_schema_has_four_fields() {
let input = empty_input();
let exec = DistributeAnalyzeExec::new(false, false, input);
let exec = DistributeAnalyzeExec::new(false, false, false, input);
assert_eq!(exec.schema().fields().len(), 4);
assert_eq!(exec.schema().field(0).name(), "phase");
assert_eq!(exec.schema().field(1).name(), "node_address");
}
#[test]
fn test_distribute_analyze_exec_schema_adds_profile_when_requested() {
let exec = DistributeAnalyzeExec::new(false, false, true, empty_input());
assert_eq!(exec.schema().fields().len(), 5);
assert_eq!(exec.schema().field(4).name(), "profile");
assert!(exec.schema().field(4).is_nullable());
}
#[tokio::test]
async fn test_distribute_analyze_exec_without_profile_emits_four_columns() {
let exec = Arc::new(DistributeAnalyzeExec::new(
true,
false,
false,
empty_input(),
));
let batches = datafusion::physical_plan::collect(exec, Arc::new(TaskContext::default()))
.await
.unwrap();
assert_eq!(batches[0].num_columns(), 4);
}
#[tokio::test]
async fn test_distribute_analyze_exec_emits_profile_on_first_row() {
let exec = Arc::new(DistributeAnalyzeExec::new(true, false, true, empty_input()));
let batches = datafusion::physical_plan::collect(exec, Arc::new(TaskContext::default()))
.await
.unwrap();
let profile = batches[0]
.column(4)
.as_any()
.downcast_ref::<StringArray>()
.unwrap();
assert!(profile.is_valid(0));
assert!((1..profile.len()).all(|i| profile.is_null(i)));
let parsed: QueryProfile = serde_json::from_str(profile.value(0)).unwrap();
assert_eq!(parsed.plan.name, "EmptyExec");
assert!(parsed.end_us >= parsed.start_us);
}
#[test]
fn test_collect_remote_rows_nests_stages_and_skips_nodes_without_plan() {
let node = |name: &str, plan: Option<ProfileNode>| RemoteNodeProfile {
node_name: name.to_string(),
plan,
..Default::default()
};
let scan = |remotes| ProfileNode {
name: "RemoteScanExec".to_string(),
inputs: ProfileInputs::Remote(remotes),
..Default::default()
};
let cluster_leader = node(
"b-querier-0",
Some(scan(vec![node(
"b-querier-1",
Some(ProfileNode::default()),
)])),
);
let root = ProfileNode {
inputs: ProfileInputs::Children(vec![scan(vec![
cluster_leader,
node("skipped", None),
])]),
..Default::default()
};
let mut rows = Vec::new();
collect_remote_rows(&root, 1, &mut rows);
let rows: Vec<_> = rows
.iter()
.map(|(stage, node, _)| (*stage, node.node_name.as_str()))
.collect();
assert_eq!(rows, vec![(1, "b-querier-0"), (2, "b-querier-1")]);
}
}
@@ -39,6 +39,7 @@ pub mod enrichment_exec;
pub mod metadata_count_exec;
pub mod node;
mod once_async;
pub mod profile;
pub mod remote_scan_exec;
pub mod rewrite;
pub mod streaming_aggs_exec;
@@ -0,0 +1,380 @@
// 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/>.
use std::fmt;
use config::{
meta::{cluster::NodeInfo, search::ScanStats},
utils::time::now_micros,
};
use datafusion::physical_plan::{
DisplayFormatType, ExecutionPlan, ExecutionPlanProperties,
metrics::{MetricValue, MetricsSet},
};
use flight::{
common::{Metrics, NodeProfiles, RemoteScanMetrics},
profile::{MetricKind, ProfileInputs, ProfileMetric, ProfileNode, RemoteNodeProfile},
};
use super::remote_scan_exec::RemoteScanExec;
/// One remote node as the leader sees it, published to its scan on drop; the default records
/// nothing.
#[derive(Debug, Default)]
pub struct NodeProfileRecorder {
profiles: Option<NodeProfiles>,
profile: RemoteNodeProfile,
}
impl NodeProfileRecorder {
pub fn start(profiles: NodeProfiles, partition: usize, node: &dyn NodeInfo) -> Self {
let role = match (node.is_querier(), node.is_ingester()) {
(true, true) => "querier,ingester",
(true, false) => "querier",
(false, true) => "ingester",
(false, false) => "",
};
Self {
profiles: Some(profiles),
profile: RemoteNodeProfile {
partition,
node_name: node.get_name(),
node_address: node.get_grpc_addr(),
role: role.to_string(),
cluster: node.get_cluster(),
start_us: now_micros(),
..Default::default()
},
}
}
/// Keeps the plan the node sent back about itself.
pub fn record_plan(&mut self, metrics: Vec<Metrics>) {
if let Some(plan) = metrics.into_iter().find_map(|m| m.profile) {
self.profile.plan = Some(plan);
}
}
pub fn mark_skipped(&mut self) {
self.profile.skipped = true;
}
pub fn finish(&mut self, metrics: &RemoteScanMetrics, scan_stats: ScanStats) {
let p = &mut self.profile;
p.output_rows = metrics.output_rows.value() as u64;
p.output_batches = metrics.batches_received.value() as u64;
p.output_bytes = metrics.bytes_received.value() as u64;
p.decode_ns = metrics.decode_time.value() as u64;
p.scan_stats = Some(scan_stats);
}
}
impl Drop for NodeProfileRecorder {
fn drop(&mut self) {
if let Some(profiles) = self.profiles.take() {
self.profile.end_us = now_micros();
let profile = std::mem::take(&mut self.profile);
profiles.lock().insert(profile.partition, profile);
}
}
}
/// A plan node's own line, exactly as EXPLAIN prints it.
struct PlanDetail<'a>(&'a dyn ExecutionPlan);
impl fmt::Display for PlanDetail<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt_as(DisplayFormatType::Default, f)
}
}
/// Profile of an executed plan; a remote scan lists the nodes it called, as its children never ran.
pub fn build_profile(plan: &dyn ExecutionPlan) -> ProfileNode {
let inputs = match plan.downcast_ref::<RemoteScanExec>() {
Some(remote) => ProfileInputs::Remote(
remote
.state
.node_profiles
.lock()
.values()
.cloned()
.collect(),
),
None => ProfileInputs::Children(
plan.children()
.into_iter()
.map(|child| build_profile(child.as_ref()))
.collect(),
),
};
ProfileNode {
name: plan.name().to_string(),
detail: PlanDetail(plan).to_string(),
partitions: plan.output_partitioning().partition_count(),
metrics: plan
.metrics()
.map(|m| convert_metrics(&m))
.unwrap_or_default(),
inputs,
}
}
/// Aggregates and orders the metrics the same way the text plan does, so `display` matches it.
fn convert_metrics(metrics: &MetricsSet) -> Vec<ProfileMetric> {
metrics
.aggregate_by_name()
.sorted_for_display()
.timestamps_removed()
.iter()
.map(|metric| {
let value = metric.value();
let (kind, number, total) = metric_value(value);
ProfileMetric {
name: value.name().to_string(),
kind,
value: number,
total,
display: value.to_string(),
}
})
.collect()
}
fn metric_value(value: &MetricValue) -> (MetricKind, u64, Option<u64>) {
let as_u64 = |v: usize| v as u64;
match value {
MetricValue::OutputRows(c)
| MetricValue::SpillCount(c)
| MetricValue::OutputBatches(c)
| MetricValue::SpilledRows(c)
| MetricValue::Count { count: c, .. } => (MetricKind::Count, as_u64(c.value()), None),
MetricValue::SpilledBytes(c) | MetricValue::OutputBytes(c) => {
(MetricKind::Bytes, as_u64(c.value()), None)
}
MetricValue::CurrentMemoryUsage(g) | MetricValue::PeakMemoryUsage { gauge: g, .. } => {
(MetricKind::Bytes, as_u64(g.value()), None)
}
MetricValue::ElapsedCompute(t) | MetricValue::Time { time: t, .. } => {
(MetricKind::Time, as_u64(t.value()), None)
}
MetricValue::Gauge { gauge: g, .. } => (MetricKind::Gauge, as_u64(g.value()), None),
MetricValue::PruningMetrics {
pruning_metrics, ..
} => {
let pruned = as_u64(pruning_metrics.pruned());
let total = pruned + as_u64(pruning_metrics.matched());
(MetricKind::Pruning, pruned, Some(total))
}
MetricValue::Ratio { ratio_metrics, .. } => (
MetricKind::Ratio,
as_u64(ratio_metrics.part()),
Some(as_u64(ratio_metrics.total())),
),
// Timestamps never get here: convert_metrics removes them first.
MetricValue::Custom { .. }
| MetricValue::StartTimestamp(_)
| MetricValue::EndTimestamp(_) => (MetricKind::Other, as_u64(value.as_usize()), None),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::array::{Int32Array, RecordBatch};
use arrow_schema::{DataType, Field, Schema};
use datafusion::{
physical_plan::{
collect,
display::DisplayableExecutionPlan,
empty::EmptyExec,
metrics::{ExecutionPlanMetricsSet, MetricBuilder},
union::UnionExec,
},
prelude::SessionContext,
};
use super::*;
use crate::datafusion::distributed_plan::node::RemoteScanNode;
#[derive(Debug)]
struct TestNode;
impl NodeInfo for TestNode {
fn is_querier(&self) -> bool {
true
}
fn get_grpc_addr(&self) -> String {
"10.0.0.1:5081".to_string()
}
fn get_auth_token(&self) -> String {
String::new()
}
fn get_name(&self) -> String {
"q-0".to_string()
}
fn is_local(&self) -> bool {
false
}
}
fn empty() -> Arc<dyn ExecutionPlan> {
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
Arc::new(EmptyExec::new(schema))
}
fn batch(rows: i32) -> RecordBatch {
let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)]));
RecordBatch::try_new(
schema,
vec![Arc::new(Int32Array::from_iter_values(0..rows))],
)
.unwrap()
}
#[test]
fn test_recorder_publishes_one_node_on_drop() {
let profiles = NodeProfiles::default();
let mut recorder = NodeProfileRecorder::start(profiles.clone(), 2, &TestNode);
let metrics = RemoteScanMetrics::new(2, &ExecutionPlanMetricsSet::new());
metrics.record_output(&batch(3));
metrics.record_output(&batch(4));
recorder.record_plan(vec![Metrics {
profile: Some(ProfileNode {
name: "FilterExec".to_string(),
..Default::default()
}),
..Default::default()
}]);
recorder.finish(
&metrics,
ScanStats {
files: 5,
..Default::default()
},
);
assert!(profiles.lock().is_empty());
drop(recorder);
let p = profiles.lock().get(&2).cloned().unwrap();
assert_eq!(p.node_name, "q-0");
assert_eq!(p.role, "querier");
assert_eq!(p.output_rows, 7);
assert_eq!(p.output_batches, 2);
assert!(p.end_us >= p.start_us);
assert_eq!(p.scan_stats.unwrap().files, 5);
assert_eq!(p.plan.unwrap().name, "FilterExec");
}
#[test]
fn test_recorder_publishes_skipped_and_unfinished_nodes() {
let profiles = NodeProfiles::default();
NodeProfileRecorder::start(profiles.clone(), 0, &TestNode).mark_skipped();
drop(NodeProfileRecorder::start(profiles.clone(), 1, &TestNode));
NodeProfileRecorder::default().mark_skipped();
let profiles = profiles.lock();
assert_eq!(profiles.len(), 2);
assert!(profiles[&0].skipped);
assert!(!profiles[&1].skipped && profiles[&1].scan_stats.is_none());
}
#[test]
fn test_convert_metrics_sums_partitions() {
let set = ExecutionPlanMetricsSet::new();
MetricBuilder::new(&set).output_rows(0).add(10);
MetricBuilder::new(&set).output_rows(1).add(30);
MetricBuilder::new(&set)
.elapsed_compute(0)
.add_duration(std::time::Duration::from_nanos(5));
let metrics = convert_metrics(&set.clone_inner());
let rows = metrics.iter().find(|m| m.name == "output_rows").unwrap();
assert_eq!(rows.kind, MetricKind::Count);
assert_eq!(rows.value, 40);
let time = metrics
.iter()
.find(|m| m.name == "elapsed_compute")
.unwrap();
assert_eq!(time.kind, MetricKind::Time);
assert_eq!(time.value, 5);
assert_eq!(time.display, "5ns");
}
#[tokio::test]
async fn test_profile_text_matches_datafusion() {
let ctx = SessionContext::new();
let sql = "SELECT a % 7 AS k, count(*) FROM (SELECT unnest(range(1, 50)) AS a) \
WHERE a > 10 GROUP BY k ORDER BY k";
let plan = ctx
.sql(sql)
.await
.unwrap()
.create_physical_plan()
.await
.unwrap();
collect(plan.clone(), ctx.task_ctx()).await.unwrap();
let expected = DisplayableExecutionPlan::with_metrics(plan.as_ref())
.indent(false)
.to_string();
let profile = build_profile(plan.as_ref());
assert_eq!(profile.to_text(), expected);
assert!(expected.contains("selectivity="), "{expected}");
}
#[test]
fn test_memory_metrics_are_bytes() {
let set = ExecutionPlanMetricsSet::new();
MetricBuilder::new(&set).mem_used(0).set(2048);
let metrics = convert_metrics(&set.clone_inner());
assert_eq!(metrics[0].kind, MetricKind::Bytes);
assert_eq!(metrics[0].value, 2048);
assert_eq!(metrics[0].display, "2.0 KB");
}
#[test]
fn test_build_profile_walks_children() {
let plan = UnionExec::try_new(vec![empty(), empty()]).unwrap();
let profile = build_profile(plan.as_ref());
assert_eq!(profile.name, "UnionExec");
let ProfileInputs::Children(children) = profile.inputs else {
panic!("expected children");
};
assert_eq!(children.len(), 2);
assert_eq!(children[0].name, "EmptyExec");
}
#[test]
fn test_build_profile_stops_at_remote() {
let node = RemoteScanNode {
nodes: vec![Arc::new(TestNode)],
opentelemetry_context: opentelemetry::Context::current(),
query_identifier: Default::default(),
search_infos: Default::default(),
index_info: Default::default(),
super_cluster_info: Default::default(),
};
let remote = RemoteScanExec::new(empty(), node).unwrap();
drop(NodeProfileRecorder::start(
remote.state.node_profiles.clone(),
0,
&TestNode,
));
let profile = build_profile(&remote);
assert_eq!(profile.name, "RemoteScanExec");
let ProfileInputs::Remote(remote) = profile.inputs else {
panic!("expected remote inputs");
};
assert_eq!(remote[0].node_name, "q-0");
}
}
@@ -13,14 +13,11 @@
// 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/>.
use std::sync::{Arc, atomic::AtomicUsize};
use std::sync::Arc;
use arrow_schema::SchemaRef;
use config::{
meta::{
inverted_index::IndexOptimizeMode,
search::{ScanStats, SearchEventType},
},
meta::{inverted_index::IndexOptimizeMode, search::SearchEventType},
utils::rand::generate_random_string,
};
use datafusion::{
@@ -35,14 +32,13 @@ use datafusion::{
},
};
use datafusion_proto::bytes::physical_plan_to_bytes_with_extension_codec;
use flight::common::{Metrics, RemoteScanMetrics};
use flight::common::{RemoteScanMetrics, RemoteScanState};
use futures::{StreamExt, TryStreamExt};
use futures_util::pin_mut;
use opentelemetry::context::FutureExt as _;
use parking_lot::Mutex;
use rand::prelude::SliceRandom;
use super::node::RemoteScanNode;
use super::{node::RemoteScanNode, profile::NodeProfileRecorder};
use crate::datafusion::{
distributed_plan::{
codec::get_physical_extension_codec,
@@ -60,10 +56,7 @@ pub struct RemoteScanExec {
remote_scan_node: RemoteScanNode,
partitions: usize,
cache: Arc<PlanProperties>,
pub scan_stats: Arc<Mutex<ScanStats>>,
pub peak_memory: Arc<AtomicUsize>,
pub partial_err: Arc<Mutex<String>>,
pub cluster_metrics: Arc<Mutex<Vec<Metrics>>>,
pub state: RemoteScanState,
pub enrich_mode_node_idx: usize,
pub metrics: ExecutionPlanMetricsSet,
}
@@ -101,53 +94,19 @@ impl RemoteScanExec {
remote_scan_node,
partitions: output_partitions,
cache,
scan_stats: Arc::new(Mutex::new(ScanStats::default())),
partial_err: Arc::new(Mutex::new(String::new())),
cluster_metrics: Arc::new(Mutex::new(Vec::new())),
peak_memory: Arc::new(AtomicUsize::new(0)),
state: RemoteScanState::default(),
enrich_mode_node_idx,
metrics: ExecutionPlanMetricsSet::new(),
})
}
pub fn scan_stats(&self) -> Arc<Mutex<ScanStats>> {
self.scan_stats.clone()
}
pub fn partial_err(&self) -> Arc<Mutex<String>> {
self.partial_err.clone()
}
pub fn cluster_metrics(&self) -> Arc<Mutex<Vec<Metrics>>> {
self.cluster_metrics.clone()
}
pub fn peak_memory(&self) -> Arc<AtomicUsize> {
self.peak_memory.clone()
}
/// The input plan bytes exactly as every follower receives them.
pub fn encoded_plan(&self) -> &[u8] {
&self.remote_scan_node.search_infos.plan
}
pub fn with_scan_stats(mut self, scan_stats: Arc<Mutex<ScanStats>>) -> Self {
self.scan_stats = scan_stats;
self
}
pub fn with_partial_err(mut self, partial_err: Arc<Mutex<String>>) -> Self {
self.partial_err = partial_err;
self
}
pub fn with_cluster_metrics(mut self, cluster_metrics: Arc<Mutex<Vec<Metrics>>>) -> Self {
self.cluster_metrics = cluster_metrics;
self
}
pub fn with_peak_memory(mut self, peak_memory: Arc<AtomicUsize>) -> Self {
self.peak_memory = peak_memory;
pub fn with_state(mut self, state: RemoteScanState) -> Self {
self.state = state;
self
}
@@ -254,10 +213,7 @@ impl ExecutionPlan for RemoteScanExec {
return Ok(self);
}
let remote_scan = Self::new(children[0].clone(), self.remote_scan_node.clone())?
.with_scan_stats(self.scan_stats.clone())
.with_partial_err(self.partial_err.clone())
.with_cluster_metrics(self.cluster_metrics.clone())
.with_peak_memory(self.peak_memory.clone())
.with_state(self.state.clone())
.with_metrics(self.metrics.clone());
Ok(Arc::new(remote_scan))
}
@@ -273,10 +229,7 @@ impl ExecutionPlan for RemoteScanExec {
partition,
partition == self.enrich_mode_node_idx,
self.input.schema().clone(),
self.scan_stats(),
self.partial_err(),
self.cluster_metrics(),
self.peak_memory(),
self.state.clone(),
baseline_metrics,
);
let stream = futures::stream::once(fut).try_flatten();
@@ -297,12 +250,15 @@ async fn get_remote_batch(
partition: usize,
enrich_mode: bool,
schema: SchemaRef,
scan_stats: Arc<Mutex<ScanStats>>,
partial_err: Arc<Mutex<String>>,
cluster_metrics: Arc<Mutex<Vec<Metrics>>>,
peak_memory: Arc<AtomicUsize>,
state: RemoteScanState,
metrics: RemoteScanMetrics,
) -> Result<SendableRecordBatchStream> {
let RemoteScanState {
scan_stats,
peak_memory,
partial_err,
node_profiles,
} = state;
let start = std::time::Instant::now();
let cfg = config::get_config();
let trace_id = remote_scan_node.query_identifier.trace_id.clone();
@@ -335,6 +291,12 @@ async fn get_remote_batch(
}
}
let mut recorder = if remote_scan_node.search_infos.is_analyze {
NodeProfileRecorder::start(node_profiles, partition, node.as_ref())
} else {
NodeProfileRecorder::default()
};
let empty_stream = EmptyStream::new(
&trace_id,
schema.clone(),
@@ -351,6 +313,7 @@ async fn get_remote_batch(
&& !enrich_mode
&& remote_scan_node.is_file_list_empty(partition)
{
recorder.mark_skipped();
return Ok(get_empty_stream(empty_stream));
}
@@ -412,9 +375,9 @@ async fn get_remote_batch(
.with_is_querier(is_querier)
.with_scan_stats(scan_stats)
.with_partial_err(partial_err.clone())
.with_cluster_metrics(cluster_metrics)
.with_peak_memory(peak_memory)
.with_start_time(start);
.with_start_time(start)
.with_node_profile(recorder);
let mut stream = FlightDecoderStream::new(stream, schema.clone(), metrics, query_context);
let stream = async_stream::stream! {
@@ -529,7 +492,7 @@ mod tests {
#[test]
fn test_remote_scan_exec_partial_err_empty() {
let exec = make_remote_exec();
assert!(exec.partial_err().lock().is_empty());
assert!(exec.state.partial_err.lock().is_empty());
}
#[test]
@@ -541,11 +504,11 @@ mod tests {
}
#[test]
fn test_remote_scan_exec_with_partial_err() {
let exec = make_remote_exec();
let new_err = Arc::new(parking_lot::Mutex::new("pre-set".to_string()));
let exec = exec.with_partial_err(new_err.clone());
assert_eq!(*exec.partial_err().lock(), "pre-set");
fn test_remote_scan_exec_with_state() {
let state = RemoteScanState::default();
*state.partial_err.lock() = "pre-set".to_string();
let exec = make_remote_exec().with_state(state);
assert_eq!(*exec.state.partial_err.lock(), "pre-set");
}
#[test]
+15 -2
View File
@@ -293,6 +293,7 @@ pub fn generate_physical_optimizer_rules(
pub async fn create_physical_plan(
ctx: &SessionContext,
sql: &str,
analyze_profile: bool,
) -> Result<Arc<dyn ExecutionPlan>> {
let plan = ctx.state().create_logical_plan(sql).await?;
// Defense in depth: reject DDL/DML/COPY plans so search SQL cannot write files on the node.
@@ -302,7 +303,7 @@ pub async fn create_physical_plan(
.with_allow_statements(false)
.verify_plan(&plan)?;
let physical_plan = ctx.state().create_physical_plan(&plan).await?;
optimize_distribute_analyze(physical_plan)
optimize_distribute_analyze(physical_plan, analyze_profile)
}
#[cfg(test)]
@@ -538,12 +539,24 @@ mod tests {
"INSERT INTO t2 VALUES (1)",
] {
assert!(
create_physical_plan(&ctx, sql).await.is_err(),
create_physical_plan(&ctx, sql, false).await.is_err(),
"should reject: {sql}"
);
}
}
#[tokio::test]
async fn test_create_physical_plan_adds_profile_only_when_asked() {
let sql = "EXPLAIN ANALYZE SELECT a FROM t";
let ctx = SessionContext::new();
register_tables(&ctx);
let plan = create_physical_plan(&ctx, sql, false).await.unwrap();
assert_eq!(plan.schema().fields().len(), 4);
let plan = create_physical_plan(&ctx, sql, true).await.unwrap();
assert_eq!(plan.schema().field(4).name(), "profile");
}
#[tokio::test]
async fn test_struct_field_access_matches_datafusion() {
let mut sql = test_sql();
@@ -28,11 +28,15 @@ use crate::datafusion::distributed_plan::{
};
// replace the AnalyzeExec to DistributeAnalyzeExec
pub fn optimize_distribute_analyze(plan: Arc<dyn ExecutionPlan>) -> Result<Arc<dyn ExecutionPlan>> {
pub fn optimize_distribute_analyze(
plan: Arc<dyn ExecutionPlan>,
profile: bool,
) -> Result<Arc<dyn ExecutionPlan>> {
if let Some(analyze) = plan.downcast_ref::<AnalyzeExec>() {
let distribute_analyze = Arc::new(DistributeAnalyzeExec::new(
analyze.verbose(),
analyze.show_statistics(),
profile,
analyze.input().clone(),
)) as Arc<dyn ExecutionPlan>;
let mut rewriter = AnalyzeRewrite::new();
@@ -78,9 +82,8 @@ mod tests {
]))
}
#[test]
fn test_optimize_distribute_analyze() {
let plan = Arc::new(
fn analyze_plan() -> Arc<dyn ExecutionPlan> {
Arc::new(
AnalyzeExec::builder(
false,
false,
@@ -89,9 +92,20 @@ mod tests {
)
.with_metric_types(vec![])
.build(),
);
let optimized_plan = optimize_distribute_analyze(plan).unwrap();
)
}
#[test]
fn test_optimize_distribute_analyze() {
let optimized_plan = optimize_distribute_analyze(analyze_plan(), false).unwrap();
assert_eq!(optimized_plan.name(), "DistributeAnalyzeExec");
assert_eq!(optimized_plan.schema().fields().len(), 4);
}
#[test]
fn test_optimize_distribute_analyze_with_profile() {
let optimized_plan = optimize_distribute_analyze(analyze_plan(), true).unwrap();
assert_eq!(optimized_plan.schema().fields().len(), 5);
}
#[test]
+2 -34
View File
@@ -15,41 +15,9 @@
use std::sync::{Arc, atomic::AtomicUsize};
use datafusion::{
common::{
Result,
tree_node::{TreeNode, TreeNodeRecursion, TreeNodeVisitor},
},
physical_plan::ExecutionPlan,
prelude::SessionContext,
};
use flight::common::Metrics;
use parking_lot::Mutex;
use datafusion::prelude::SessionContext;
use super::{distributed_plan::remote_scan_exec::RemoteScanExec, peak_memory_pool::PeakMemoryPool};
pub fn get_cluster_metrics(plan: &Arc<dyn ExecutionPlan>) -> Vec<Arc<Mutex<Vec<Metrics>>>> {
let mut visitor = MetricsVisitor::default();
let _ = plan.visit(&mut visitor);
visitor.metrics
}
#[derive(Default)]
struct MetricsVisitor {
metrics: Vec<Arc<Mutex<Vec<Metrics>>>>,
}
impl<'n> TreeNodeVisitor<'n> for MetricsVisitor {
type Node = Arc<dyn ExecutionPlan>;
fn f_up(&mut self, node: &'n Self::Node) -> Result<TreeNodeRecursion> {
if node.name() == "RemoteScanExec" {
let remote_scan_exec = node.downcast_ref::<RemoteScanExec>().unwrap();
self.metrics.push(remote_scan_exec.cluster_metrics());
}
Ok(TreeNodeRecursion::Continue)
}
}
use super::peak_memory_pool::PeakMemoryPool;
/// Get the peak memory from the SessionContext's memory pool.
/// This should be called after the query execution to get the peak memory usage.
+3 -3
View File
@@ -87,17 +87,17 @@ impl ExecutionPlanVisitor for ScanStatsVisitor {
let mayby_remote_scan_exec = plan.downcast_ref::<RemoteScanExec>();
if let Some(remote_scan_exec) = mayby_remote_scan_exec {
{
let guard = remote_scan_exec.scan_stats.lock();
let guard = remote_scan_exec.state.scan_stats.lock();
let stats = *guard;
self.scan_stats.add(&stats);
}
{
let guard = remote_scan_exec.partial_err.lock();
let guard = remote_scan_exec.state.partial_err.lock();
let err = (*guard).clone();
self.partial_err.push_str(&err);
}
{
let peak_memory = remote_scan_exec.peak_memory().load(Ordering::Relaxed);
let peak_memory = remote_scan_exec.state.peak_memory.load(Ordering::Relaxed);
self.peak_memory = peak_memory.max(self.peak_memory);
}
}
+6 -4
View File
@@ -397,10 +397,12 @@ pub async fn run_datafusion(
register_table(&ctx, &sql).await?;
// create physical plan
let physical_plan = create_physical_plan(&ctx, &sql.sql).await.map_err(|e| {
log::error!("[trace_id {trace_id}] flight->search: create physical plan error: {e}");
e
})?;
let physical_plan = create_physical_plan(&ctx, &sql.sql, req.analyze_profile)
.await
.map_err(|e| {
log::error!("[trace_id {trace_id}] flight->search: create physical plan error: {e}");
e
})?;
if cfg.common.print_key_sql {
log::info!("[trace_id {trace_id}] leader physical plan");
+1
View File
@@ -164,6 +164,7 @@ pub async fn search(
}
request.set_use_cache(in_req.use_cache);
request.set_use_index(in_req.use_index);
request.set_analyze_profile(in_req.query.analyze_profile);
// Before the query is planned again in spawned tasks (cluster::http).
request.mark_chat_history_reader();
let meta = Sql::new_from_req(&request, &query).await?;
@@ -243,7 +243,7 @@ async fn run_datafusion(
register_table(&ctx, &sql).await?;
// create physical plan
let physical_plan = create_physical_plan(&ctx, &sql.sql).await?;
let physical_plan = create_physical_plan(&ctx, &sql.sql, req.analyze_profile).await?;
if cfg.common.print_key_sql {
log::info!("[trace_id {trace_id}] super cluster leader: physical plan");
+107 -26
View File
@@ -13,7 +13,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/>.
use std::{future::Future, io::Error};
use std::{collections::HashSet, future::Future, io::Error};
use arrow_schema::{DataType, Field, Schema};
use axum::{http, response::Response as HttpResponse};
@@ -93,33 +93,31 @@ pub async fn get_streams(
fetch_schema: bool,
permitted_streams: Option<Vec<String>>,
) -> Vec<Stream> {
let indices = db::schema::list(org_id, stream_type, fetch_schema)
try_get_streams(org_id, stream_type, fetch_schema, permitted_streams)
.await
.unwrap_or_default();
.unwrap_or_default()
}
let filtered_indices = if let Some(s_type) = stream_type {
let s_type = match s_type {
StreamType::EnrichmentTables => "enrichment_table",
_ => s_type.as_str(),
};
match permitted_streams {
Some(permitted_streams) => {
if permitted_streams.contains(&format!("{s_type}:_all_{org_id}")) {
indices
} else {
indices
.into_iter()
.filter(|stream_loc| {
permitted_streams
.contains(&format!("{}:{}", s_type, stream_loc.stream_name))
})
.collect::<Vec<_>>()
}
}
None => indices,
/// Like [`get_streams`] but reports a failed schema listing instead of returning an empty list.
pub async fn try_get_streams(
org_id: &str,
stream_type: Option<StreamType>,
fetch_schema: bool,
permitted_streams: Option<Vec<String>>,
) -> Result<Vec<Stream>, anyhow::Error> {
let indices = db::schema::list(org_id, stream_type, fetch_schema).await?;
let filtered_indices = match permitted_streams {
Some(permitted) => {
let grants: HashSet<&str> = permitted.iter().map(String::as_str).collect();
indices
.into_iter()
.filter(|loc| {
is_stream_permitted(&grants, org_id, loc.stream_type, &loc.stream_name)
})
.collect::<Vec<_>>()
}
} else {
indices
None => indices,
};
let mut indices_res = Vec::with_capacity(filtered_indices.len());
for stream_loc in filtered_indices {
@@ -155,7 +153,7 @@ pub async fn get_streams(
));
}
}
indices_res
Ok(indices_res)
}
pub fn stream_res(
@@ -373,6 +371,24 @@ pub async fn save_stream_settings(
Ok(MetaHttpResponse::ok(""))
}
/// Checks a stream against the caller's permitted object list, using the stream's own type.
fn is_stream_permitted(
permitted: &HashSet<&str>,
org_id: &str,
stream_type: StreamType,
stream_name: &str,
) -> bool {
let s_type = match stream_type {
StreamType::EnrichmentTables => "enrichment_table",
_ => stream_type.as_str(),
};
if permitted.contains(format!("{s_type}:_all_{org_id}").as_str()) {
return true;
}
let grant_name = config::utils::str::into_ofga_supported_format(stream_name);
permitted.contains(format!("{s_type}:{grant_name}").as_str())
}
async fn sync_associated_metadata_stream_retention(
org_id: &str,
stream_name: &str,
@@ -1208,6 +1224,71 @@ mod tests {
// unique org so the process-wide schema caches can host these tests in parallel
const SETTINGS_TEST_ORG: &str = "settings_cache_test_org";
#[test]
fn test_is_stream_permitted_uses_each_streams_own_type() {
let permitted = HashSet::from(["logs:app", "metrics:cpu"]);
assert!(is_stream_permitted(
&permitted,
"o",
StreamType::Logs,
"app"
));
assert!(is_stream_permitted(
&permitted,
"o",
StreamType::Metrics,
"cpu"
));
assert!(!is_stream_permitted(
&permitted,
"o",
StreamType::Logs,
"cpu"
));
assert!(!is_stream_permitted(
&permitted,
"o",
StreamType::Traces,
"app"
));
}
#[test]
fn test_is_stream_permitted_matches_sanitized_grant_names() {
let permitted = HashSet::from(["logs:app.v2"]);
assert!(is_stream_permitted(
&permitted,
"o",
StreamType::Logs,
"app:v2"
));
assert!(!is_stream_permitted(
&permitted,
"o",
StreamType::Logs,
"app_v2"
));
}
#[test]
fn test_is_stream_permitted_all_grant_and_enrichment_key() {
let all = HashSet::from(["logs:_all_o"]);
assert!(is_stream_permitted(&all, "o", StreamType::Logs, "anything"));
assert!(!is_stream_permitted(
&all,
"o",
StreamType::Metrics,
"anything"
));
let enrich = HashSet::from(["enrichment_table:geo"]);
assert!(is_stream_permitted(
&enrich,
"o",
StreamType::EnrichmentTables,
"geo"
));
}
#[test]
fn test_associated_metadata_streams() {
assert_eq!(
@@ -14,6 +14,33 @@ const OVERVIEW_SECTIONS = [
/\/api\/v2\/[^/]+\/alerts\/incidents/,
/\/api\/[^/]+\/traces\/service_graph\/topology\/current/,
];
const OVERVIEW_ENDPOINT = /\/api\/[^/]+\/overview(\?.*)?$/;
const FIRING_RULE = 'checkout_latency_p95';
// one firing rule, pipelines refused and streams failed, so every section state shows on one page
function overviewWithGaps() {
const nowUs = Date.now() * 1000;
return {
streams: { status: 'error', error: 'stubbed failure' },
alerts: {
status: 'ok',
data: {
total: 4,
enabled: 4,
disabled: 0,
by_state: { firing: 1, normal: 3, notify_failed: 0 },
by_level: { ok: 0, warning: 0, critical: 1, no_data: 0, unclassified: 0 },
failed_evaluation: 0,
firing: [{ name: FIRING_RULE, stream: 'checkout_traces', level: 'critical', since: nowUs - 25 * 60 * 1_000_000 }],
firing_by_level: { ok: 0, warning: 0, critical: 1, no_data: 0, unclassified: 0 },
},
},
pipelines: { status: 'forbidden' },
limits: { status: 'locked' },
generated_at: nowUs,
cached: false,
};
}
// Cloud lets only root create orgs, so the empty screens use the stack's one empty org and never write to it.
async function emptyOrgId(page, name) {
@@ -81,6 +108,26 @@ async function failSections(page, status, which = OVERVIEW_SECTIONS) {
}
}
async function answerSectionsEmpty(page) {
await page.route(OVERVIEW_SECTIONS[0], (route) => route.fulfill({ json: { total: 0, hits: [] } }));
await page.route(OVERVIEW_SECTIONS[1], (route) => route.fulfill({ json: [] }));
await page.route(OVERVIEW_SECTIONS[2], (route) => route.fulfill({ json: { incidents: [], total: 0 } }));
await page.route(OVERVIEW_SECTIONS[3], (route) => route.fulfill({ json: { nodes: [], edges: [] } }));
}
async function stubOverview(page, reply) {
await page.route(OVERVIEW_ENDPOINT, (route) => route.fulfill(reply));
}
// an org whose summary counts no rules takes the not-monitored hero, which would hide the cards under test
async function pinSummaryRules(page) {
await page.route(/\/api\/[^/]+\/summary(\?.*)?$/, async (route) => {
const response = await route.fetch();
const body = await response.json().catch(() => ({}));
await route.fulfill({ response, json: { ...body, alerts: { ...(body.alerts || {}), num_realtime: 4, num_scheduled: 0 } } });
});
}
async function openHome(page, orgId, tab) {
await page.evaluate((t) => localStorage.setItem('o2_home_active_tab', t), tab);
await page.goto(`${base()}/web/?org_identifier=${orgId}`);
@@ -91,7 +138,6 @@ const tid = (page, id) => page.locator(`[data-test="${id}"]`);
async function expectNoOverviewToolbar(page) {
await expect(tid(page, 'date-time-btn')).toHaveCount(0);
await expect(page.locator('[data-test="home-page"] [data-test="overview-all-clear-empty-state"]')).toHaveCount(0);
}
const SCREENS = [
@@ -169,7 +215,6 @@ const SCREENS = [
await expect(tid(page, 'first-data-panel-arrived-summary')).toHaveText('1,204 records in stream default');
await expect(tid(page, 'overview-not-monitored-empty-state')).toBeVisible({ timeout: 20000 });
await expect(tid(page, 'overview-not-monitored-alert-card')).toBeVisible();
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
expect(loads).toEqual([]);
},
},
@@ -180,24 +225,19 @@ const SCREENS = [
await openHome(page, orgId, 'overview');
await expect(tid(page, 'overview-load-error-empty-state')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-load-error-retry-card')).toBeVisible();
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await expect(tid(page, 'first-data-panel')).toHaveCount(0);
},
},
{
id: 'h-home-overview-summary-error',
async init({ page }) {
await page.route(OVERVIEW_SECTIONS[0], (route) => route.fulfill({ json: { total: 0, hits: [] } }));
await page.route(OVERVIEW_SECTIONS[1], (route) => route.fulfill({ json: [] }));
await page.route(OVERVIEW_SECTIONS[2], (route) => route.fulfill({ json: { incidents: [], total: 0 } }));
await page.route(OVERVIEW_SECTIONS[3], (route) => route.fulfill({ json: { nodes: [], edges: [] } }));
await answerSectionsEmpty(page);
await page.route(/\/api\/[^/]+\/summary(\?.*)?$/, (route) => route.fulfill({ status: 500, json: { code: 500, message: 'stubbed failure' } }));
},
async run({ page, orgId }) {
await openHome(page, orgId, 'overview');
await expect(tid(page, 'overview-load-error-empty-state')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-load-error-retry-card')).toBeVisible();
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await expect(tid(page, 'overview-not-monitored-empty-state')).toHaveCount(0);
},
},
@@ -283,7 +323,6 @@ const SCREENS = [
async run({ page, orgId }) {
await openHome(page, orgId, 'overview');
await expect(tid(page, 'overview-load-error-empty-state')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await expect(tid(page, 'first-data-panel')).toHaveCount(0);
await expect(tid(page, 'first-event-status')).toHaveCount(0);
},
@@ -307,7 +346,6 @@ const SCREENS = [
await expect(tid(page, 'date-time-btn')).toBeVisible();
await expect(page.locator('[data-test="home-page"] [data-test="refresh-button"]')).toBeVisible();
await expect(page.locator('[data-test="home-page"]').getByText('just now')).toBeVisible();
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await expect(tid(page, 'first-data-panel')).toHaveCount(0);
await expect(tid(page, 'first-event-status')).toHaveCount(0);
// the load's one-shot first-data notice probes are all; no watcher polls behind the flag-off hero
@@ -334,7 +372,6 @@ const SCREENS = [
await openHome(page, orgId, 'overview');
await expect(tid(page, 'overview-load-error-empty-state')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-load-error-retry-card')).toBeVisible();
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await expect(tid(page, 'home-first-data-hero')).toHaveCount(0);
await expect(tid(page, 'first-data-panel')).toHaveCount(0);
await expect(page.locator('[data-test="home-page"]').getByText('just now')).toHaveCount(0);
@@ -342,7 +379,6 @@ const SCREENS = [
await tid(page, 'overview-load-error-retry-card').click();
await expect.poll(() => streamReads.length, { timeout: 15000 }).toBeGreaterThan(beforeRetry);
await expect(tid(page, 'overview-load-error-empty-state')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
await page.mouse.move(760, 880);
},
},
@@ -379,7 +415,69 @@ const SCREENS = [
await expect(tid(page, 'overview-recent-events-section')).toBeVisible();
await expect(tid(page, 'overview-services-section')).toBeVisible();
await expect(tid(page, 'overview-load-error-empty-state')).toHaveCount(0);
await expect(tid(page, 'overview-all-clear-empty-state')).toHaveCount(0);
},
},
{
id: 'h-home-overview-section-states',
async init({ page }) {
await answerSectionsEmpty(page);
await pinSummaryRules(page);
await stubOverview(page, { json: overviewWithGaps() });
},
async run({ page, orgId }) {
await openHome(page, orgId, 'overview');
const tileValue = (key) => page.locator(`[data-test="overview-headline-${key}"] [data-test="overview-tile-value"]`);
const tileNote = (key) => page.locator(`[data-test="overview-headline-${key}"] [data-test="overview-tile-note"]`);
await expect(tid(page, 'overview-status-danger')).toBeVisible({ timeout: 30000 });
await expect(tid(page, 'overview-status-text')).toHaveText('Needs attention');
await expect(page.locator('[data-test^="overview-status-item-"]')).toHaveText(['1 alert firing']);
await expect(tid(page, 'overview-status-clear')).toHaveCount(0);
await expect(tileValue('firing')).toHaveText('1');
await expect(tileValue('failing')).toHaveText('0');
await expect(tileValue('delivery')).toHaveText('0');
await expect(tileValue('pipelines')).toHaveText('—');
await expect(tileNote('pipelines')).toHaveText('No access');
await expect(tileValue('quiet')).toHaveText('—');
await expect(tileNote('quiet')).toHaveText('Could not load');
await expect(tid(page, `overview-alerts-firing-${FIRING_RULE}`)).toBeVisible();
await expect(tid(page, 'overview-alerts-error')).toHaveCount(0);
await expect(tid(page, 'overview-alerts-forbidden')).toHaveCount(0);
await expect(tid(page, 'overview-data-health-status-pipelines')).toHaveText('No access');
await expect(tid(page, 'overview-data-health-value-pipelines')).toHaveText('—');
await expect(tid(page, 'overview-data-health-status-quiet')).toHaveText('Could not load');
await expect(tid(page, 'overview-data-health-value-quiet')).toHaveText('—');
await expect(tid(page, 'overview-data-health-retry-btn')).toHaveCount(0);
await expect(tid(page, 'overview-streams-error')).toBeVisible();
await expect(tid(page, 'overview-streams-retry-btn')).toBeVisible();
await expect(tid(page, 'overview-streams-top')).toHaveCount(0);
await expect(tid(page, 'overview-load-error-empty-state')).toHaveCount(0);
await expect(tid(page, 'overview-no-access-empty-state')).toHaveCount(0);
},
},
{
id: 'h-home-overview-forbidden',
async init({ page }) {
await failSections(page, 403);
await stubOverview(page, { status: 403, json: { code: 403, message: 'Unauthorized Access' } });
},
async run({ page, orgId }) {
await openHome(page, orgId, 'overview');
const noAccess = tid(page, 'overview-no-access-empty-state');
await expect(noAccess).toBeVisible({ timeout: 30000 });
await expect(noAccess).toContainText("You don't have access");
await expect(tid(page, 'overview-load-error-empty-state')).toHaveCount(0);
await expect(tid(page, 'overview-load-error-retry-card')).toHaveCount(0);
await expect(page.locator('[data-test$="-retry-btn"]')).toHaveCount(0);
await expect(tid(page, 'overview-headline-strip')).toHaveCount(0);
await expect(page.locator('[data-test="home-page"] [data-test="refresh-button"]')).toHaveCount(0);
await expectNoOverviewToolbar(page);
await expect(page.locator('[data-test^="o-toast-"]')).toHaveCount(0);
},
},
];
@@ -19,7 +19,12 @@ import ChartRenderer from "./ChartRenderer.vue";
import i18n from "@/locales";
import { raw } from "@/types/i18n";
import store from "@/test/unit/helpers/store";
import { use as echartsUse } from "echarts/core";
import { use as echartsUse, init as echartsInit } from "echarts/core";
import {
fakeCanvasPainter,
fakeDevicePixelRatio,
type DevicePixelRatioFake,
} from "@/test/unit/helpers/devicePixelRatioFake";
import {
BoxplotChart,
FunnelChart,
@@ -1783,3 +1788,116 @@ describe("ChartRenderer", () => {
});
});
});
describe("ChartRenderer device pixel ratio", () => {
let display: DevicePixelRatioFake;
let wrapper: any;
let zr: any;
const mountAt = async (renderType = "canvas") => {
wrapper = mount(ChartRenderer, {
props: {
data: { options: { series: [{ type: "line", data: [[1609459200000, 10]] }] } },
renderType,
},
global: { plugins: [i18n], provide: { store, hoveredSeriesState: ref(null) } },
});
await flushPromises();
};
const initRatios = () =>
vi.mocked(echartsInit).mock.calls.map(([, , opts]) => opts?.devicePixelRatio);
beforeEach(() => {
vi.mocked(echartsInit).mockClear();
global.IntersectionObserver = vi.fn(function () {
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
}) as any;
store.state.theme = "light";
});
afterEach(() => {
wrapper?.unmount();
display.restore();
// The mocked instance and its zr are shared by every test in this file.
if (zr) delete zr.painter;
zr = null;
});
it("inits the chart at the live ratio, not the one zrender cached at import", async () => {
display = fakeDevicePixelRatio(2);
await mountAt();
expect(initRatios()).toEqual([2]);
expect(wrapper.classes()).not.toContain("chart-canvas-pixelated");
});
it("pixel-snaps its canvases only while the ratio is fractional", async () => {
display = fakeDevicePixelRatio(1.25);
await mountAt();
const pixelated = () => wrapper.classes().includes("chart-canvas-pixelated");
expect(pixelated()).toBe(true);
display.setRatio(2);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1.5);
await flushPromises();
expect(pixelated()).toBe(true);
});
const attachPainter = (type: "canvas" | "svg" = "canvas") => {
const chart = vi.mocked(echartsInit).mock.results[0].value;
const painter = fakeCanvasPainter(1, type);
zr = chart.getZr();
zr.painter = painter;
chart.setOption.mockClear();
chart.dispose.mockClear();
return { chart, painter };
};
it("re-rasterizes the mounted chart in place when the window changes display", async () => {
display = fakeDevicePixelRatio(1);
await mountAt();
const { chart, painter } = attachPainter();
display.setRatio(2);
await flushPromises();
expect(initRatios()).toEqual([1]);
expect(chart.dispose).not.toHaveBeenCalled();
expect(chart.setOption).not.toHaveBeenCalled();
expect(painter.dpr).toBe(2);
expect(painter.layers["zr_0.0"].ctx.dpr).toBe(2);
expect(painter.refresh).toHaveBeenCalledWith({ paintAll: true });
display.setRatio(1.5);
await flushPromises();
expect(initRatios()).toEqual([1]);
expect(painter.dpr).toBe(1.5);
});
it("leaves an SVG chart alone, since it does not rasterize at a ratio", async () => {
display = fakeDevicePixelRatio(1);
await mountAt("svg");
const { chart, painter } = attachPainter("svg");
display.setRatio(2);
await flushPromises();
expect(initRatios()).toEqual([1]);
expect(chart.dispose).not.toHaveBeenCalled();
expect(painter.refresh).not.toHaveBeenCalled();
});
it("stops listening for ratio changes on unmount", async () => {
display = fakeDevicePixelRatio(1);
await mountAt();
expect(display.listenerCount()).toBe(1);
wrapper.unmount();
wrapper = null;
expect(display.listenerCount()).toBe(0);
});
});
@@ -22,6 +22,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:role="label ? 'img' : undefined"
:aria-label="label || undefined"
class="chart-container h-full w-full"
:class="{ 'chart-canvas-pixelated': fractionalPixelRatio }"
@mouseover="
() => {
// if hoveredSeriesState is not null then set panelId
@@ -84,6 +85,12 @@ import type { I18nText } from "@/types/i18n";
import { useStore } from "vuex";
import useBreakpoint from "@/composables/useBreakpoint";
import { useTheme } from "@/composables/useTheme";
import {
applyDevicePixelRatio,
currentDevicePixelRatio,
isFractionalDevicePixelRatio,
useDevicePixelRatioChange,
} from "@/composables/useDevicePixelRatio";
import { chartColor } from "@/utils/chartTheme";
import * as echarts from "echarts/core";
import {
@@ -927,45 +934,53 @@ export default defineComponent({
},
);
// ECharts fixes the theme at init, so a theme change needs a fresh instance.
const rebuildChart = (theme: "dark" | "light") => {
cleanupChart();
chart = echarts.init(chartRef.value, theme, {
renderer: props.renderType,
devicePixelRatio: currentDevicePixelRatio(),
});
trackPointer();
const options = props.data.options || {};
// change color and background color of tooltip
options.tooltip &&
options.tooltip.textStyle &&
(options.tooltip.textStyle.color = chartColor("--color-tooltip-text"));
if (options.tooltip) {
options.tooltip.backgroundColor = chartColor("--color-tooltip-bg");
options.tooltip.borderColor = chartColor("--color-tooltip-border");
options.tooltip.borderWidth = 1;
}
options.animation = false;
try {
// Use notMerge flag from data prop if available, otherwise default to true
const notMerge = props.data?.notMerge !== undefined ? props.data.notMerge : true;
const lazyUpdate = props.data?.lazyUpdate !== undefined ? props.data.lazyUpdate : true;
chart?.setOption(chartOptions(options), { lazyUpdate, notMerge });
chart?.setOption({ animation: true }, { lazyUpdate: true });
} catch (e: any) {
emit("error", {
message: e,
code: "",
});
}
chartInitialSetUp();
};
watch(
() => store.state.theme,
(newTheme) => {
const theme = newTheme === "dark" ? "dark" : "light";
cleanupChart();
chart = echarts.init(chartRef.value, theme, {
renderer: props.renderType,
devicePixelRatio: window.devicePixelRatio || 1,
});
trackPointer();
const options = props.data.options || {};
// change color and background color of tooltip
options.tooltip &&
options.tooltip.textStyle &&
(options.tooltip.textStyle.color = chartColor("--color-tooltip-text"));
if (options.tooltip) {
options.tooltip.backgroundColor = chartColor("--color-tooltip-bg");
options.tooltip.borderColor = chartColor("--color-tooltip-border");
options.tooltip.borderWidth = 1;
}
options.animation = false;
try {
// Use notMerge flag from data prop if available, otherwise default to true
const notMerge = props.data?.notMerge !== undefined ? props.data.notMerge : true;
const lazyUpdate = props.data?.lazyUpdate !== undefined ? props.data.lazyUpdate : true;
chart?.setOption(chartOptions(options), { lazyUpdate, notMerge });
chart?.setOption({ animation: true }, { lazyUpdate: true });
} catch (e: any) {
emit("error", {
message: e,
code: "",
});
}
chartInitialSetUp();
},
(newTheme) => rebuildChart(newTheme === "dark" ? "dark" : "light"),
);
const fractionalPixelRatio = ref(isFractionalDevicePixelRatio(currentDevicePixelRatio()));
useDevicePixelRatioChange((ratio) => {
fractionalPixelRatio.value = isFractionalDevicePixelRatio(ratio);
applyDevicePixelRatio(chart, ratio);
});
// Repairs a chart that ECharts initialized while the element had no
// layout size (e.g. mounted mid-splitter/flex settle). In that state
// ECharts creates zero-width canvas layers but may still record the
@@ -988,6 +1003,7 @@ export default defineComponent({
cleanupChart();
chart = echarts.init(chartRef.value, theme, {
renderer: props.renderType,
devicePixelRatio: currentDevicePixelRatio(),
});
trackPointer();
chart?.setOption(chartOptions(props?.data?.options || {}), {
@@ -1043,6 +1059,7 @@ export default defineComponent({
cleanupChart();
chart = echarts.init(chartRef.value, theme, {
renderer: props.renderType,
devicePixelRatio: currentDevicePixelRatio(),
});
trackPointer();
if (chartRef.value.clientWidth === 0 || chartRef.value.clientHeight === 0) {
@@ -1139,6 +1156,7 @@ export default defineComponent({
cleanupChart();
chart = echarts.init(chartRef.value, theme, {
renderer: props.renderType,
devicePixelRatio: currentDevicePixelRatio(),
});
trackPointer();
}
@@ -1232,6 +1250,7 @@ export default defineComponent({
return {
chartRef,
chartId,
fractionalPixelRatio,
hoveredSeriesState,
handleNativeContextMenu,
onChartMouseLeave,
@@ -18,6 +18,11 @@ import { flushPromises, mount, VueWrapper, config } from "@vue/test-utils";
import { createStore } from "vuex";
import i18n from "@/locales";
import CustomChartRenderer from "./CustomChartRenderer.vue";
import {
fakeCanvasPainter,
fakeDevicePixelRatio,
type DevicePixelRatioFake,
} from "@/test/unit/helpers/devicePixelRatioFake";
config.global.plugins = [...(config.global.plugins ?? []), i18n];
@@ -29,6 +34,7 @@ const mockChart = {
on: vi.fn(),
off: vi.fn(),
clear: vi.fn(),
getZr: vi.fn((): any => ({})),
};
// Mock ECharts
@@ -234,6 +240,7 @@ describe("CustomChartRenderer", () => {
expect(echarts.init).toHaveBeenCalledWith(expect.any(Object), undefined, {
renderer: "canvas",
devicePixelRatio: 1,
});
});
});
@@ -423,6 +430,7 @@ describe("CustomChartRenderer", () => {
expect(echarts.init).toHaveBeenCalledWith(expect.any(Object), undefined, {
renderer: "canvas",
devicePixelRatio: 1,
});
expect(lastOption()).not.toHaveProperty("backgroundColor");
});
@@ -436,6 +444,7 @@ describe("CustomChartRenderer", () => {
expect(echarts.init).toHaveBeenCalledWith(expect.any(Object), "dark", {
renderer: "canvas",
devicePixelRatio: 1,
});
expect(lastOption().backgroundColor).toBe("transparent");
});
@@ -490,6 +499,7 @@ describe("CustomChartRenderer", () => {
expect(mockChart.dispose).toHaveBeenCalledTimes(1);
expect(echarts.init).toHaveBeenLastCalledWith(expect.any(Object), "dark", {
renderer: "canvas",
devicePixelRatio: 1,
});
});
});
@@ -813,4 +823,75 @@ describe("CustomChartRenderer", () => {
expect(chartElement.element.tabIndex).toBeDefined();
});
});
describe("Device pixel ratio", () => {
let display: DevicePixelRatioFake;
afterEach(() => {
display.restore();
mockChart.getZr.mockImplementation(() => ({}));
});
it("inits at the live ratio", async () => {
const echarts = await import("echarts");
display = fakeDevicePixelRatio(2);
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(echarts.init).toHaveBeenCalledWith(expect.any(Object), undefined, {
renderer: "canvas",
devicePixelRatio: 2,
});
expect(wrapper.classes()).not.toContain("chart-canvas-pixelated");
});
it("pixel-snaps its canvases only while the ratio is fractional", async () => {
display = fakeDevicePixelRatio(1.25);
wrapper = createWrapper();
await waitForChartInit(wrapper);
const pixelated = () => wrapper.classes().includes("chart-canvas-pixelated");
expect(pixelated()).toBe(true);
display.setRatio(2);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1.5);
await flushPromises();
expect(pixelated()).toBe(true);
});
it("re-rasterizes the instance in place when the window changes display", async () => {
const echarts = await import("echarts");
display = fakeDevicePixelRatio(1);
wrapper = createWrapper();
await waitForChartInit(wrapper);
const painter = fakeCanvasPainter(1);
mockChart.getZr.mockImplementation(() => ({ painter }));
mockChart.setOption.mockClear();
display.setRatio(2);
await flushPromises();
expect(echarts.init).toHaveBeenCalledTimes(1);
expect(mockChart.dispose).not.toHaveBeenCalled();
expect(mockChart.clear).not.toHaveBeenCalled();
expect(mockChart.setOption).not.toHaveBeenCalled();
expect(painter.dpr).toBe(2);
expect(painter.refresh).toHaveBeenCalledWith({ paintAll: true });
});
it("stops listening on unmount", async () => {
display = fakeDevicePixelRatio(1);
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(display.listenerCount()).toBe(1);
wrapper.unmount();
wrapper = undefined as any;
expect(display.listenerCount()).toBe(0);
});
});
});
@@ -19,6 +19,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div
class="h-full w-full"
:class="{ 'chart-canvas-pixelated': fractionalPixelRatio }"
data-test="chart-renderer"
ref="chartRef"
id="chart"
@@ -56,6 +57,12 @@ import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
import DOMPurify from "dompurify";
import { withChartFont } from "@/utils/fonts";
import { useTheme } from "@/composables/useTheme";
import {
applyDevicePixelRatio,
currentDevicePixelRatio,
isFractionalDevicePixelRatio,
useDevicePixelRatioChange,
} from "@/composables/useDevicePixelRatio";
import { getColorPalette } from "@/utils/dashboard/colorPalette";
// Register necessary components.
@@ -146,6 +153,7 @@ export default defineComponent({
if (!chart) {
chart = echarts.init(chartRef.value, dark ? "dark" : undefined, {
renderer: "canvas",
devicePixelRatio: currentDevicePixelRatio(),
});
chartDark = dark;
if (chartRef.value.clientWidth === 0 || chartRef.value.clientHeight === 0) {
@@ -234,6 +242,11 @@ export default defineComponent({
{ deep: true },
);
watch(isDark, () => initChart());
const fractionalPixelRatio = ref(isFractionalDevicePixelRatio(currentDevicePixelRatio()));
useDevicePixelRatioChange((ratio) => {
fractionalPixelRatio.value = isFractionalDevicePixelRatio(ratio);
applyDevicePixelRatio(chart, ratio);
});
onUnmounted(() => {
zeroSizeReinitObserver?.disconnect();
@@ -243,7 +256,7 @@ export default defineComponent({
window.removeEventListener("resize", handleResize);
});
return { chartRef, handleMouseOver, handleMouseLeave };
return { chartRef, fractionalPixelRatio, handleMouseOver, handleMouseLeave };
},
});
</script>
@@ -14,8 +14,9 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import GeoMapRenderer from "./GeoMapRenderer.vue";
import { fakeCanvasPainter, fakeDevicePixelRatio } from "@/test/unit/helpers/devicePixelRatioFake";
// Mock Leaflet
const mockLeafletMap = {
@@ -51,6 +52,7 @@ const mockChart = {
dispose: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getZr: vi.fn((): any => ({})),
getModel: vi.fn(() => ({
getComponent: vi.fn((name) => {
if (name === "lmap") {
@@ -274,6 +276,85 @@ describe("GeoMapRenderer", () => {
expect(echarts.init).toHaveBeenCalled();
});
it("inits at the live device pixel ratio, not the one zrender cached at import", async () => {
const echarts = await import("echarts/core");
const display = fakeDevicePixelRatio(2);
try {
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), undefined, {
devicePixelRatio: 2,
});
expect(wrapper.find("#chart-map").classes()).not.toContain("chart-canvas-pixelated");
} finally {
display.restore();
}
});
it("pixel-snaps its canvases only while the ratio is fractional", async () => {
const display = fakeDevicePixelRatio(1.25);
try {
wrapper = createWrapper();
await waitForChartInit(wrapper);
const pixelated = () =>
wrapper.find("#chart-map").classes().includes("chart-canvas-pixelated");
expect(pixelated()).toBe(true);
display.setRatio(2);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1.5);
await flushPromises();
expect(pixelated()).toBe(true);
} finally {
display.restore();
}
});
it("re-rasterizes in place, keeping the map's pan and zoom, when the window changes display", async () => {
const echarts = await import("echarts/core");
const display = fakeDevicePixelRatio(1);
const painter = fakeCanvasPainter(1);
try {
wrapper = createWrapper();
await waitForChartInit(wrapper);
mockChart.getZr.mockImplementation(() => ({ painter }));
mockChart.setOption.mockClear();
mockLeafletMap.setView.mockClear();
display.setRatio(2);
expect(echarts.init).toHaveBeenCalledTimes(1);
expect(mockChart.dispose).not.toHaveBeenCalled();
expect(mockChart.setOption).not.toHaveBeenCalled();
expect(mockLeafletMap.setView).not.toHaveBeenCalled();
expect(painter.dpr).toBe(2);
expect(painter.refresh).toHaveBeenCalledWith({ paintAll: true });
} finally {
display.restore();
mockChart.getZr.mockImplementation(() => ({}));
}
});
it("stops listening for ratio changes on unmount", async () => {
const display = fakeDevicePixelRatio(1);
try {
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(display.listenerCount()).toBe(1);
wrapper.unmount();
wrapper = undefined as any;
expect(display.listenerCount()).toBe(0);
} finally {
display.restore();
}
});
it("should set chart options", async () => {
wrapper = createWrapper();
await waitForChartInit(wrapper);
@@ -16,7 +16,12 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="h-full w-full p-1.25" data-test="dashboard-geomap-renderer">
<div class="h-full w-full" ref="chartRef" id="chart-map"></div>
<div
class="h-full w-full"
:class="{ 'chart-canvas-pixelated': fractionalPixelRatio }"
ref="chartRef"
id="chart-map"
></div>
</div>
</template>
@@ -56,6 +61,12 @@ import { LabelLayout, UniversalTransition } from "echarts/features";
import { CanvasRenderer, SVGRenderer } from "echarts/renderers";
import { withChartFont } from "@/utils/fonts";
import { reclusterSeries } from "@/utils/dashboard/geoMapCluster";
import {
applyDevicePixelRatio,
currentDevicePixelRatio,
isFractionalDevicePixelRatio,
useDevicePixelRatioChange,
} from "@/composables/useDevicePixelRatio";
echarts.use([
TitleComponent,
@@ -152,6 +163,12 @@ export default defineComponent({
appliedView = view;
};
const fractionalPixelRatio = ref(isFractionalDevicePixelRatio(currentDevicePixelRatio()));
useDevicePixelRatioChange((ratio) => {
fractionalPixelRatio.value = isFractionalDevicePixelRatio(ratio);
applyDevicePixelRatio(chart, ratio);
});
onMounted(async () => {
await nextTick();
await nextTick();
@@ -161,7 +178,9 @@ export default defineComponent({
await nextTick();
await nextTick();
if (chartRef.value) {
chart = echarts.init(chartRef.value);
chart = echarts.init(chartRef.value, undefined, {
devicePixelRatio: currentDevicePixelRatio(),
});
}
applyOptions();
window.addEventListener("resize", windowResizeEventCallback);
@@ -183,7 +202,7 @@ export default defineComponent({
},
{ deep: true },
);
return { chartRef };
return { chartRef, fractionalPixelRatio };
},
});
</script>
@@ -14,10 +14,15 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mount, VueWrapper } from "@vue/test-utils";
import { flushPromises, mount, VueWrapper } from "@vue/test-utils";
import * as echarts from "echarts/core";
import MapsRenderer from "./MapsRenderer.vue";
import { chartColor } from "@/utils/chartTheme";
import {
fakeCanvasPainter,
fakeDevicePixelRatio,
type DevicePixelRatioFake,
} from "@/test/unit/helpers/devicePixelRatioFake";
// Create mock chart instance
const mockChart = {
@@ -27,6 +32,7 @@ const mockChart = {
dispose: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getZr: vi.fn((): any => ({})),
};
// Mock ECharts core
@@ -508,7 +514,9 @@ describe("MapsRenderer", () => {
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), "light");
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), "light", {
devicePixelRatio: 1,
});
const [options] = mockChart.setOption.mock.calls.at(-1)!;
expect(options.series).toEqual(mockMapData.options.series);
});
@@ -531,7 +539,9 @@ describe("MapsRenderer", () => {
await wrapper.vm.$nextTick();
expect(mockChart.dispose).toHaveBeenCalledTimes(1);
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), "dark");
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), "dark", {
devicePixelRatio: 1,
});
const [options] = mockChart.setOption.mock.calls.at(-1)!;
expect(options.series).toEqual(mockMapData.options.series);
expect(options.tooltip.backgroundColor).toBe(chartColor("--color-tooltip-bg"));
@@ -589,4 +599,73 @@ describe("MapsRenderer", () => {
expect(options.series[0].zoom).toBeUndefined();
});
});
describe("Device pixel ratio", () => {
let display: DevicePixelRatioFake;
afterEach(() => {
display.restore();
mockChart.getZr.mockImplementation(() => ({}));
});
it("inits at the live ratio", async () => {
display = fakeDevicePixelRatio(2);
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(echarts.init).toHaveBeenCalledWith(expect.any(HTMLElement), "light", {
devicePixelRatio: 2,
});
expect(wrapper.find("#chart-map").classes()).not.toContain("chart-canvas-pixelated");
});
it("pixel-snaps its canvases only while the ratio is fractional", async () => {
display = fakeDevicePixelRatio(1.25);
wrapper = createWrapper();
await waitForChartInit(wrapper);
const pixelated = () =>
wrapper.find("#chart-map").classes().includes("chart-canvas-pixelated");
expect(pixelated()).toBe(true);
display.setRatio(2);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1);
await flushPromises();
expect(pixelated()).toBe(false);
display.setRatio(1.5);
await flushPromises();
expect(pixelated()).toBe(true);
});
it("re-rasterizes in place, keeping the instance and the user's view, when the window changes display", async () => {
display = fakeDevicePixelRatio(1);
wrapper = createWrapper({
data: { options: { series: [{ type: "map", map: "world", roam: true, data: [] }] } },
});
await waitForChartInit(wrapper);
const painter = fakeCanvasPainter(1);
mockChart.getZr.mockImplementation(() => ({ painter }));
mockChart.setOption.mockClear();
display.setRatio(2);
expect(echarts.init).toHaveBeenCalledTimes(1);
expect(mockChart.dispose).not.toHaveBeenCalled();
expect(mockChart.setOption).not.toHaveBeenCalled();
expect(painter.dpr).toBe(2);
expect(painter.refresh).toHaveBeenCalledWith({ paintAll: true });
});
it("stops listening on unmount", async () => {
display = fakeDevicePixelRatio(1);
wrapper = createWrapper();
await waitForChartInit(wrapper);
expect(display.listenerCount()).toBe(1);
wrapper.unmount();
wrapper = undefined as any;
expect(display.listenerCount()).toBe(0);
});
});
});
@@ -12,7 +12,12 @@
<template>
<div class="h-full w-full p-1.25">
<div class="h-full w-full" ref="chartRef" id="chart-map"></div>
<div
class="h-full w-full"
:class="{ 'chart-canvas-pixelated': fractionalPixelRatio }"
ref="chartRef"
id="chart-map"
></div>
</div>
</template>
@@ -24,6 +29,12 @@ import worldMap from "@/assets/dashboard/maps/map.json";
import { withChartFont } from "@/utils/fonts";
import { chartColor } from "@/utils/chartTheme";
import useTheme from "@/composables/useTheme";
import {
applyDevicePixelRatio,
currentDevicePixelRatio,
isFractionalDevicePixelRatio,
useDevicePixelRatioChange,
} from "@/composables/useDevicePixelRatio";
echarts.use([MapChart]);
// Registering once per app keeps panels and theme switches from re-parsing the GeoJSON.
@@ -114,13 +125,20 @@ export default defineComponent({
if (!chartRef.value) return;
const view = currentView();
chart?.dispose();
chart = echarts.init(chartRef.value, isDark.value ? "dark" : "light");
chart = echarts.init(chartRef.value, isDark.value ? "dark" : "light", {
devicePixelRatio: currentDevicePixelRatio(),
});
chart.setOption(withChartFont(currentOptions(view)), true);
};
watch(isDark, () => {
if (chart) initChart();
});
const fractionalPixelRatio = ref(isFractionalDevicePixelRatio(currentDevicePixelRatio()));
useDevicePixelRatioChange((ratio) => {
fractionalPixelRatio.value = isFractionalDevicePixelRatio(ratio);
applyDevicePixelRatio(chart, ratio);
});
onMounted(async () => {
await nextTick();
@@ -148,7 +166,7 @@ export default defineComponent({
},
{ deep: true },
);
return { chartRef };
return { chartRef, fractionalPixelRatio };
},
});
</script>
@@ -0,0 +1,257 @@
// 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 { defineComponent, h } from "vue";
import { mount } from "@vue/test-utils";
import * as echarts from "echarts/core";
import { LineChart } from "echarts/charts";
import { GridComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import {
applyDevicePixelRatio,
currentDevicePixelRatio,
isFractionalDevicePixelRatio,
useDevicePixelRatioChange,
watchDevicePixelRatio,
} from "./useDevicePixelRatio";
import {
fakeCanvasPainter,
fakeDevicePixelRatio,
type DevicePixelRatioFake,
} from "@/test/unit/helpers/devicePixelRatioFake";
describe("useDevicePixelRatio", () => {
let display: DevicePixelRatioFake;
beforeEach(() => {
display = fakeDevicePixelRatio(1);
});
afterEach(() => {
display.restore();
});
it("reads the ratio live rather than once", () => {
expect(currentDevicePixelRatio()).toBe(1);
display.setRatio(2);
expect(currentDevicePixelRatio()).toBe(2);
});
it("reports every change, re-arming on the new ratio each time", () => {
const onChange = vi.fn();
watchDevicePixelRatio(onChange);
display.setRatio(2);
display.setRatio(1.5);
display.setRatio(2);
expect(onChange.mock.calls).toEqual([[2], [1.5], [2]]);
expect(display.listenerCount()).toBe(1);
});
it("reports changes on Safari before 16, which has only the prefixed query and addListener", () => {
display.restore();
display = fakeDevicePixelRatio(1, { legacyWebKit: true });
const onChange = vi.fn();
const stop = watchDevicePixelRatio(onChange);
display.setRatio(2);
display.setRatio(1.5);
expect(onChange.mock.calls).toEqual([[2], [1.5]]);
expect(display.listenerCount()).toBe(1);
stop();
expect(display.listenerCount()).toBe(0);
});
it("stops listening once stopped", () => {
const onChange = vi.fn();
const stop = watchDevicePixelRatio(onChange);
stop();
display.setRatio(2);
expect(onChange).not.toHaveBeenCalled();
expect(display.listenerCount()).toBe(0);
});
it("listens only while the component is mounted", () => {
const onChange = vi.fn();
const wrapper = mount(
defineComponent({
setup() {
useDevicePixelRatioChange(onChange);
return () => h("div");
},
}),
);
expect(display.listenerCount()).toBe(1);
display.setRatio(2);
expect(onChange).toHaveBeenCalledWith(2);
wrapper.unmount();
expect(display.listenerCount()).toBe(0);
display.setRatio(1);
expect(onChange).toHaveBeenCalledTimes(1);
});
});
describe("isFractionalDevicePixelRatio", () => {
it("is false at whole ratios and true at scaled or zoomed ones", () => {
expect([1, 2, 3].map(isFractionalDevicePixelRatio)).toEqual([false, false, false]);
expect([1.25, 1.5, 0.9, 1.100000023841858].map(isFractionalDevicePixelRatio)).toEqual([
true,
true,
true,
true,
]);
});
});
describe("applyDevicePixelRatio", () => {
const chartWith = (painter: unknown) => ({ getZr: () => ({ painter }) });
it("re-rasterizes every canvas layer at the new ratio and repaints all of it", () => {
const painter = fakeCanvasPainter(1);
expect(applyDevicePixelRatio(chartWith(painter), 2)).toBe(true);
expect(painter.dpr).toBe(2);
Object.values(painter.layers).forEach((layer) => {
expect(layer.dpr).toBe(2);
expect(layer.ctx.dpr).toBe(2);
expect(layer.resize).toHaveBeenCalledWith(400, 300);
});
expect(painter.refresh).toHaveBeenCalledExactlyOnceWith({ paintAll: true });
});
it("orders the ratio update before the resize, since a layer sizes its canvas from its own ratio", () => {
const painter = fakeCanvasPainter(1);
const seen: number[] = [];
painter.layers["zr_0.0"].resize.mockImplementation(function (this: { dpr: number }) {
seen.push(this.dpr);
});
applyDevicePixelRatio(chartWith(painter), 1.5);
expect(seen).toEqual([1.5]);
});
it("does nothing for an SVG painter", () => {
const painter = fakeCanvasPainter(1, "svg");
expect(applyDevicePixelRatio(chartWith(painter), 2)).toBe(false);
expect(painter.dpr).toBe(1);
expect(painter.refresh).not.toHaveBeenCalled();
});
it("does nothing when the ratio is unchanged", () => {
const painter = fakeCanvasPainter(2);
expect(applyDevicePixelRatio(chartWith(painter), 2)).toBe(false);
expect(painter.layers["zr_0.0"].resize).not.toHaveBeenCalled();
expect(painter.refresh).not.toHaveBeenCalled();
});
it("leaves a chart with an echarts-gl layer untouched, so its WebGL viewport stays in sync", () => {
const painter = fakeCanvasPainter(1);
const glLayer = { dpr: 1, resize: vi.fn() };
painter.getLayers.mockReturnValue({ ...painter.layers, gl: glLayer });
expect(applyDevicePixelRatio(chartWith(painter), 2)).toBe(false);
expect(painter.dpr).toBe(1);
expect(glLayer.dpr).toBe(1);
expect(glLayer.resize).not.toHaveBeenCalled();
expect(painter.layers["zr_0.0"].dpr).toBe(1);
expect(painter.layers["zr_0.0"].resize).not.toHaveBeenCalled();
expect(painter.refresh).not.toHaveBeenCalled();
});
it("touches nothing when zrender's internals are not the expected shape", () => {
const noLayers = { ...fakeCanvasPainter(1), getLayers: undefined };
const brokenLayer = fakeCanvasPainter(1);
brokenLayer.getLayers.mockReturnValue({ ...brokenLayer.layers, odd: { dpr: 1, ctx: {} } });
expect(applyDevicePixelRatio(chartWith(noLayers), 2)).toBe(false);
expect(applyDevicePixelRatio(chartWith(brokenLayer), 2)).toBe(false);
expect(applyDevicePixelRatio({ getZr: () => undefined }, 2)).toBe(false);
expect(applyDevicePixelRatio(null, 2)).toBe(false);
expect(noLayers.dpr).toBe(1);
expect(brokenLayer.dpr).toBe(1);
expect(brokenLayer.layers["zr_0.0"].dpr).toBe(1);
expect(brokenLayer.layers["zr_0.0"].resize).not.toHaveBeenCalled();
expect(brokenLayer.refresh).not.toHaveBeenCalled();
});
});
describe("applyDevicePixelRatio on a real ECharts canvas chart", () => {
let el: HTMLDivElement;
let chart: echarts.ECharts;
beforeEach(() => {
echarts.use([LineChart, GridComponent, CanvasRenderer]);
el = document.createElement("div");
document.body.appendChild(el);
chart = echarts.init(el, null, {
renderer: "canvas",
devicePixelRatio: 1,
width: 400,
height: 300,
});
chart.setOption({
animation: false,
xAxis: { type: "category", data: ["a", "b", "c"] },
yAxis: { type: "value" },
series: [{ type: "line", data: [1, 3, 2] }],
});
});
afterEach(() => {
chart.dispose();
el.remove();
});
// The fake painter only models zrender, so an upgrade that renames its internals would pass every other spec here.
it("re-rasterizes the canvas at the new ratio and keeps the same instance", () => {
expect(applyDevicePixelRatio(chart, 2)).toBe(true);
const canvases = [...el.querySelectorAll("canvas")];
expect(canvases.length).toBeGreaterThan(0);
canvases.forEach((canvas) => {
expect(canvas.width).toBe(800);
expect(canvas.height).toBe(600);
});
expect(chart.getDevicePixelRatio()).toBe(2);
expect(echarts.getInstanceByDom(el)).toBe(chart);
});
it("builds a hover layer created after the change at the new ratio", () => {
const before = el.querySelectorAll("canvas").length;
applyDevicePixelRatio(chart, 2);
(chart.getZr().painter as unknown as { getHoverLayer: () => unknown }).getHoverLayer();
const canvases = [...el.querySelectorAll("canvas")];
expect(canvases.length).toBe(before + 1);
canvases.forEach((canvas) => {
expect(canvas.width).toBe(800);
});
});
});
+134
View File
@@ -0,0 +1,134 @@
// 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 { onMounted, onUnmounted } from "vue";
interface CanvasLayer {
dpr: number;
ctx: { dpr?: number };
resize: (width: number, height: number) => void;
}
interface CanvasPainter {
type: string;
dpr: number;
getLayers: () => Record<string, Partial<CanvasLayer> | undefined>;
getWidth: () => number;
getHeight: () => number;
refresh: (opt: { paintAll: boolean }) => unknown;
}
type LegacyMediaQueryList = Partial<
Pick<MediaQueryList, "matches" | "addEventListener" | "removeEventListener">
> & {
addListener?: (fn: () => void) => void;
removeListener?: (fn: () => void) => void;
};
/** The ratio right now; ECharts' own default is the one read when zrender was first imported. */
export const currentDevicePixelRatio = (): number =>
(typeof window !== "undefined" && window.devicePixelRatio) || 1;
/** Whether one CSS pixel spans a non-whole number of device pixels (Windows display scaling, browser zoom), where the browser resamples canvases. */
export const isFractionalDevicePixelRatio = (ratio: number): boolean => !Number.isInteger(ratio);
/** Calls `onChange` with the new ratio each time it changes (display move, browser zoom); returns a stop function. */
export const watchDevicePixelRatio = (onChange: (ratio: number) => void): (() => void) => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
let mql: LegacyMediaQueryList | null = null;
const disarm = () => {
if (typeof mql?.removeEventListener === "function")
mql.removeEventListener("change", handleChange);
else mql?.removeListener?.(handleChange);
mql = null;
};
// A resolution query only flips once, when the ratio leaves the value it was built for, so it is rebuilt after each change.
const arm = () => {
mql = ratioQuery(currentDevicePixelRatio());
if (typeof mql?.addEventListener === "function") mql.addEventListener("change", handleChange);
else mql?.addListener?.(handleChange);
};
function handleChange() {
disarm();
arm();
onChange(currentDevicePixelRatio());
}
arm();
return disarm;
};
/** Component-scoped `watchDevicePixelRatio`: listens while mounted, stops on unmount. */
export const useDevicePixelRatioChange = (onChange: (ratio: number) => void): void => {
let stop = () => {};
onMounted(() => {
stop = watchDevicePixelRatio(onChange);
});
onUnmounted(() => stop());
};
/** Re-rasterizes a mounted canvas chart at `ratio` in place, keeping its instance, handlers and interaction state; returns whether it changed anything. */
export const applyDevicePixelRatio = (
chart: { getZr?: () => unknown } | null | undefined,
ratio: number,
): boolean => {
// ECharts has no public setter for the ratio, so this updates zrender 6.1's canvas painter fields directly.
const painter = (chart?.getZr?.() as { painter?: Partial<CanvasPainter> } | undefined)?.painter;
if (!painter || painter.type !== "canvas" || painter.dpr === ratio) return false;
const { getLayers, getWidth, getHeight, refresh } = painter;
if (
typeof getLayers !== "function" ||
typeof getWidth !== "function" ||
typeof getHeight !== "function" ||
typeof refresh !== "function"
) {
return false;
}
const layers = Object.values(getLayers.call(painter) ?? {}) as Partial<CanvasLayer>[];
// An echarts-gl layer has no 2D context and sizes its viewport from painter.dpr, so the whole chart is left as is.
if (
!layers.every(
(layer): layer is CanvasLayer => !!layer?.ctx && typeof layer.resize === "function",
)
) {
return false;
}
const width = getWidth.call(painter);
const height = getHeight.call(painter);
// Layers made later (hover, incremental) are built from painter.dpr, so setting it covers them too.
painter.dpr = ratio;
layers.forEach((layer) => {
layer.dpr = ratio;
layer.ctx.dpr = ratio;
layer.resize(width, height);
});
refresh.call(painter, { paintAll: true });
return true;
};
const ratioQuery = (ratio: number): LegacyMediaQueryList | null => {
const standard: LegacyMediaQueryList | null =
window.matchMedia(`(resolution: ${ratio}dppx)`) || null;
if (standard?.matches) return standard;
// Safari before 16 matches only the prefixed form of the ratio query.
const prefixed: LegacyMediaQueryList | null =
window.matchMedia(`(-webkit-device-pixel-ratio: ${ratio})`) || null;
return prefixed?.matches ? prefixed : standard;
};
export default useDevicePixelRatioChange;
+55 -1
View File
@@ -43,6 +43,58 @@ describe("OButton", () => {
expect(classes).toContain("opacity-60");
});
it("row variant lays out start name and end value on an auto-height compact row", () => {
const classes = mount(OButton, { props: { variant: "row", block: true } }).classes();
for (const c of ["h-auto!", "justify-between!", "gap-3!", "px-0!", "py-1.5!", "text-start"]) {
expect(classes).toContain(c);
}
expect(classes).toContain("focus-visible:ring-3");
});
it("locked-body variant starts and wraps its message on an outlined body", () => {
const classes = mount(OButton, { props: { variant: "locked-body", block: true } }).classes();
for (const c of [
"h-auto!",
"justify-start!",
"whitespace-normal!",
"px-3!",
"py-3!",
"border",
]) {
expect(classes).toContain(c);
}
});
it("row and locked-body dim when unavailable but focusable", () => {
const row = mount(OButton, {
props: { variant: "row", disabled: true, focusableUnavailable: true },
}).classes();
expect(row).toContain("text-text-disabled");
const locked = mount(OButton, {
props: { variant: "locked-body", disabled: true, focusableUnavailable: true },
}).classes();
expect(locked).toContain("opacity-50");
});
it.each(["row", "locked-body"] satisfies ButtonVariant[])(
"%s keeps start alignment, focus and aria-disabled when unavailable",
async (variant) => {
const wrapper = mount(OButton, {
props: { variant, disabled: true, focusableUnavailable: true },
attachTo: document.body,
});
expect(wrapper.classes()).toContain("text-start");
expect(wrapper.classes()).toContain("focus-visible:ring-3");
expect(wrapper.attributes("disabled")).toBeUndefined();
expect(wrapper.attributes("aria-disabled")).toBe("true");
(wrapper.element as HTMLElement).focus();
expect(document.activeElement).toBe(wrapper.element);
await wrapper.trigger("click");
expect(wrapper.emitted("click")).toBeUndefined();
wrapper.unmount();
},
);
it('defaults to type="button"', () => {
const wrapper = mount(OButton);
expect(wrapper.attributes("type")).toBe("button");
@@ -460,6 +512,8 @@ describe("OButton", () => {
"tint-purple",
"tint-blue",
"tint-orange",
"row",
"locked-body",
] satisfies ButtonVariant[])(
"preserves native disabled styling for %s when unavailable",
(variant) => {
@@ -480,7 +534,7 @@ describe("OButton", () => {
.filter(
(value) =>
groups.includes(value.split("-")[0]) &&
!/^text-(xs|sm|base|lg|compact|inherit)$/.test(value),
!/^text-(xs|sm|base|lg|compact|inherit|start)$/.test(value),
)
.sort(),
).toEqual(disabledClasses.sort());
+5 -1
View File
@@ -66,7 +66,11 @@ export type ButtonVariant =
| "tint-blue"
| "tint-orange"
// Table-cell content that is itself the action: no chrome, full cell width, stacked lines.
| "cell";
| "cell"
// Borderless list row whose whole content is the action.
| "row"
// Outlined prompt whose message is the action, e.g. a locked section.
| "locked-body";
/** Size controls height, padding, font-size, and border-radius */
export type ButtonSize =
+24 -3
View File
@@ -297,6 +297,24 @@ const variantClasses: Record<NonNullable<ButtonProps["variant"]>, string> = {
"focus-visible:ring-3 focus-visible:ring-button-ghost-focus-ring",
"disabled:opacity-60",
].join(" "),
// row: list row whose content is itself the action.
row: [
"bg-transparent text-button-ghost-text border-0 text-start rounded-default",
"h-auto! justify-between! gap-3! px-0! py-1.5!",
"enabled:hover:bg-button-ghost-hover-bg",
"enabled:active:bg-button-ghost-active-bg",
"focus-visible:ring-3 focus-visible:ring-button-ghost-focus-ring",
"disabled:text-text-disabled",
].join(" "),
// locked-body: outlined prompt whose message is the action.
"locked-body": [
"bg-transparent text-button-outline-text border border-button-outline-border text-start",
"h-auto! justify-start! px-3! py-3! whitespace-normal!",
"enabled:hover:bg-button-outline-hover-bg enabled:hover:border-button-outline-hover-border",
"enabled:active:bg-button-outline-active-bg",
"focus-visible:ring-3 focus-visible:ring-button-outline-hover-border",
"disabled:opacity-50",
].join(" "),
// pricing-chip: Pill-shaped toggle chip for model pricing quick-setup template selection
"pricing-chip": [
"bg-transparent text-inherit border border-border-default",
@@ -371,6 +389,8 @@ const disabledVariantClasses: Record<NonNullable<ButtonProps["variant"]>, string
"tint-blue": "opacity-60",
"tint-orange": "opacity-60",
cell: "opacity-60",
row: "text-text-disabled",
"locked-body": "opacity-50",
};
const unavailableClasses = computed(() => {
@@ -381,9 +401,10 @@ const unavailableClasses = computed(() => {
.split(" ")
.filter(
(value) =>
!value.startsWith("enabled:") &&
!value.startsWith("disabled:") &&
!overriddenGroups.includes(value.split("-")[0]),
value === "text-start" ||
(!value.startsWith("enabled:") &&
!value.startsWith("disabled:") &&
!overriddenGroups.includes(value.split("-")[0])),
),
...disabledClasses,
"cursor-not-allowed!",
@@ -221,6 +221,29 @@ describe("OEmptyState", () => {
expect(wrapper.find("p").exists()).toBe(true);
});
it("renders a text-only p title for slim, with no icon or illustration", () => {
wrapper = buildWrapper({ size: "slim" });
expect(wrapper.find("h2").exists()).toBe(false);
expect(wrapper.find("p").exists()).toBe(true);
expect(wrapper.find('[data-test="o-icon-stub"]').exists()).toBe(false);
expect(wrapper.find('[data-test="illustration-stub"]').exists()).toBe(false);
});
it("draws no dot-grid backdrop for slim or inline unless asked for", () => {
expect(buildWrapper({ size: "slim" }).find('[aria-hidden="true"].absolute').exists()).toBe(
false,
);
expect(buildWrapper({ size: "inline" }).find('[aria-hidden="true"].absolute').exists()).toBe(
false,
);
expect(
buildWrapper({ size: "slim", backdrop: true })
.find('[aria-hidden="true"].absolute')
.exists(),
).toBe(true);
});
it("does not render the illustration when size is inline", () => {
// Arrange & Act — inline never renders the full illustration
wrapper = buildWrapper({ size: "inline" });
+23 -9
View File
@@ -21,6 +21,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
• hero full page / section (dashboards list, alerts page) — big
illustration, rich copy, fills its container, dot-grid backdrop
• block inside a card or panel — medium illustration, tighter copy
• slim one text line inside a card section — no icon, no illustration
• inline inside a table body / dropdown — tiny: just icon + one line
Driven by a PRESET (the common path) or by props/slots (the escape hatch):
@@ -43,7 +44,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
]"
data-test="o2-empty-state"
>
<!-- decorative dot-grid backdrop (hero/block only) -->
<!-- decorative dot-grid backdrop (hero, block and compact only) -->
<div
v-if="showBackdrop"
aria-hidden="true"
@@ -83,7 +84,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div :class="['flex max-w-xl flex-col', sizeClass.copy]">
<component
:is="size === 'inline' ? 'p' : 'h2'"
:is="isInlineCopy ? 'p' : 'h2'"
:class="['text-text-heading font-medium tracking-[-0.01em]', sizeClass.title]"
>
<slot name="title">{{ resolvedTitle }}</slot>
@@ -130,7 +131,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
v-if="resolvedActionLabel"
:variant="actionVariant"
:size="size === 'inline' ? 'sm' : 'md'"
:size="isInlineCopy ? 'sm' : 'md'"
:icon-left="resolvedActionIcon"
:data-test="`o2-empty-state-action-${actionId}`"
:data-empty-state-action="actionId"
@@ -141,7 +142,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
v-if="secondaryActionLabel"
variant="ghost"
:size="size === 'inline' ? 'sm' : 'md'"
:size="isInlineCopy ? 'sm' : 'md'"
:data-test="`o2-empty-state-action-${secondaryActionId}`"
:data-empty-state-action="secondaryActionId"
@click="emit('secondaryAction')"
@@ -178,7 +179,7 @@ import {
type EmptyStateVariant,
} from "./presets";
type Size = "hero" | "block" | "compact" | "inline";
type Size = "hero" | "block" | "compact" | "inline" | "slim";
const props = withDefaults(
defineProps<{
@@ -241,6 +242,7 @@ const preset = computed<EmptyStatePreset | undefined>(() =>
);
const size = computed(() => props.size);
const isInlineCopy = computed(() => size.value === "inline" || size.value === "slim");
// --- filtered (search/filter matched nothing) vs first-run ------------------
const isFiltered = computed(() => !!props.filtered);
@@ -275,7 +277,7 @@ const resolvedActions = computed<EmptyStateAction[]>(() => {
if (isFiltered.value) return [FILTERED_ACTION];
return props.actions ?? preset.value?.actions ?? [];
});
const showCards = computed(() => size.value !== "inline" && resolvedActions.value.length > 0);
const showCards = computed(() => !isInlineCopy.value && resolvedActions.value.length > 0);
// Simple button fallback (only when a call site passes actionLabel directly and
// there are no cards).
@@ -297,10 +299,10 @@ const illustrationComponent = computed(() =>
);
// inline never shows the full illustration — it uses a compact icon instead.
const hasIllustration = computed(
() => size.value !== "inline" && (!!slots.illustration || !!illustrationComponent.value),
() => !isInlineCopy.value && (!!slots.illustration || !!illustrationComponent.value),
);
const inlineIcon = computed<IconName | undefined>(() => {
if (size.value !== "inline") return undefined;
if (size.value !== "inline") return undefined; // slim is text only
if (props.icon) return props.icon;
// sensible default icon per tone
return variant.value === "error" ? "error-outline" : "search-off";
@@ -322,7 +324,7 @@ onMounted(() => {
onBeforeUnmount(() => mq?.removeEventListener?.("change", syncMotion));
// --- backdrop ---------------------------------------------------------------
const showBackdrop = computed(() => props.backdrop ?? size.value !== "inline");
const showBackdrop = computed(() => props.backdrop ?? !isInlineCopy.value);
const dotGridStyle =
// eslint-disable-next-line local/no-hardcoded-px -- hairline: a 1-device-pixel rule must not scale with text or it smears at fractional zoom
"background-image: radial-gradient(var(--empty-dot) 1.25px, transparent 1.25px);" +
@@ -386,6 +388,18 @@ const SIZE_MAP: Record<
illustration: "max-md:[&>svg]:h-auto max-md:[&>svg]:w-28",
iconWrap: "",
},
slim: {
root: "w-full min-h-0 px-4 py-3",
stack: "gap-0",
copy: "gap-0",
actions: "gap-2 pt-1",
extra: "w-full flex flex-col items-center gap-1.5 pt-1",
title: raw("text-sm!"),
description: raw("text-xs"),
illustrationWidth: 0,
illustration: "",
iconWrap: "",
},
inline: {
root: "w-full min-h-40 px-4 py-8 max-md:min-h-0 max-md:py-5",
stack: "gap-3",
@@ -3,6 +3,7 @@ import { mount } from "@vue/test-utils";
import { createI18n } from "vue-i18n";
import enLocale from "@/locales/languages/en-US.json";
import ORefreshButton from "./ORefreshButton.vue";
import { raw } from "@/types/i18n";
// The component calls useI18n() in setup, so an i18n instance must be installed
// or vue-i18n throws "Need to install with app.use function". Source the real
@@ -136,6 +137,16 @@ describe("ORefreshButton", () => {
expect(ran.find("button").attributes("aria-label")).toBe("Refresh, last refreshed 57s ago");
});
it("shows a passed tooltip instead of the last-refreshed time", () => {
const wrapper = mount(ORefreshButton, {
props: { lastRunAt: Date.now() - 3_000, tooltip: raw("Data as of 16:19") },
global: globalConfig,
});
const tips = wrapper.findAll("[data-stub-tooltip]").map((tip) => tip.text());
expect(tips).toContain("Data as of 16:19");
expect(tips.some((tip) => tip.startsWith("Last refreshed"))).toBe(false);
});
// --- layout="inline" ---
describe("size", () => {
@@ -1,3 +1,5 @@
import type { I18nText } from "@/types/i18n";
export interface RefreshButtonProps {
/** Unix millisecond timestamp of the last completed query */
lastRunAt?: number | null;
@@ -13,6 +15,8 @@ export interface RefreshButtonProps {
dataTest?: string;
/** Registry id, so the tooltip shows the page's refresh shortcut. */
shortcutId?: string;
/** Replaces the last-refreshed time in the tooltip, for data that carries its own as-of time. */
tooltip?: I18nText;
/** `inset` sizes the split-layout button to fill a 1.5rem bordered pill, so the pill need not clip it. */
size?: "toolbar" | "inset";
}
@@ -15,6 +15,7 @@ const props = withDefaults(defineProps<RefreshButtonProps>(), {
layout: "split",
dataTest: "refresh-button",
shortcutId: undefined,
tooltip: undefined,
size: "toolbar",
});
@@ -71,6 +72,7 @@ const buttonName = computed(() =>
);
const exactTime = computed(() => {
if (props.tooltip) return props.tooltip;
if (!props.lastRunAt) return t("refreshButton.notYetRefreshed");
return t("refreshButton.lastRefreshed", {
time: new Date(props.lastRunAt).toLocaleTimeString(),
@@ -67,6 +67,27 @@ describe("OSegmentedBar", () => {
);
});
it("paints the track and packs segments flush unless gapped", () => {
const plain = mount(OSegmentedBar, { props: { segments: [{ id: "a", pct: 50 }] } });
expect(plain.classes()).toContain("bg-border-default");
expect(find(plain, "o-segmented-bar-segment-a").classes()).toContain("shrink-0");
const gapped = mount(OSegmentedBar, {
props: {
gapped: true,
segments: [
{ id: "a", pct: 0.5, variant: "logs" },
{ id: "b", pct: 0 },
],
},
});
expect(gapped.classes()).not.toContain("bg-border-default");
expect(gapped.find(".gap-0\\.5").exists()).toBe(true);
expect(find(gapped, "o-segmented-bar-segment-a").classes()).toEqual(
expect.arrayContaining(["min-w-1", "shrink", "bg-signal-logs"]),
);
expect(find(gapped, "o-segmented-bar-segment-b").classes()).toContain("hidden");
});
it("exposes an image role when labelled and hides itself otherwise", () => {
const labelled = mount(OSegmentedBar, { props: { segments: [], label: raw("Pods") } });
expect(labelled.attributes("role")).toBe("img");
@@ -78,7 +99,7 @@ describe("OSegmentedBar", () => {
it("exports one swatch class per variant and the bar paints with the same classes", () => {
const variants = Object.keys(SEGMENT_SWATCH) as SegmentedBarVariant[];
expect(variants).toHaveLength(8);
expect(variants).toHaveLength(11);
const w = mount(OSegmentedBar, {
props: { segments: variants.map((variant) => ({ id: variant, pct: 1, variant })) },
});
@@ -2,9 +2,19 @@
import type { I18nText } from "@/types/i18n";
/** Fill colour of a segment or marker; `default` is the brand accent and `muted`/`contrast` are non-semantic greys */
/** Fill colour of a segment or marker; `default` is the brand accent, `muted`/`contrast` are non-semantic greys and `logs`/`metrics`/`traces` are the signal hues */
export type SegmentedBarVariant =
"default" | "success" | "warning" | "danger" | "info" | "neutral" | "muted" | "contrast";
| "default"
| "success"
| "warning"
| "danger"
| "info"
| "neutral"
| "muted"
| "contrast"
| "logs"
| "metrics"
| "traces";
/** Background token class per variant; legends reuse it so their dots match the bar */
export const SEGMENT_SWATCH: Record<SegmentedBarVariant, string> = {
@@ -16,6 +26,9 @@ export const SEGMENT_SWATCH: Record<SegmentedBarVariant, string> = {
neutral: "bg-status-neutral-text",
muted: "bg-border-strong",
contrast: "bg-text-secondary",
logs: "bg-signal-logs",
metrics: "bg-signal-metrics",
traces: "bg-signal-traces",
};
/** Height of the track */
@@ -41,6 +54,8 @@ export interface SegmentedBarProps {
markers?: SegmentedBarMarker[];
/** Track height. Default: "sm" */
size?: SegmentedBarSize;
/** Separates segments with a small gap and keeps every non-empty one visible; the track is left unpainted, so pass a remainder segment */
gapped?: boolean;
/** Accessible name; without it the bar is hidden from assistive tech because a legend is expected beside it */
label?: I18nText;
}
@@ -20,18 +20,25 @@ const fill = (variant: SegmentedBarVariant | undefined) => SEGMENT_SWATCH[varian
<template>
<div
class="bg-border-default relative flex w-full min-w-0 rounded-full"
:class="sizeClasses[props.size]"
class="relative flex w-full min-w-0 rounded-full"
:class="[sizeClasses[props.size], props.gapped ? '' : 'bg-border-default']"
:role="props.label ? 'img' : undefined"
:aria-label="props.label"
:aria-hidden="props.label ? undefined : 'true'"
>
<div class="flex h-full w-full overflow-hidden rounded-full">
<div
class="flex h-full w-full overflow-hidden rounded-full"
:class="props.gapped ? 'gap-0.5' : ''"
>
<!-- a gapped bar lets segments shrink so the gaps and minimum widths still fit the track -->
<div
v-for="segment in props.segments"
:key="segment.id"
class="h-full shrink-0 transition-[width] duration-300"
:class="fill(segment.variant)"
class="h-full transition-[width] duration-300"
:class="[
fill(segment.variant),
props.gapped ? (segment.pct > 0 ? 'min-w-1 shrink' : 'hidden') : 'shrink-0',
]"
:style="{ width: clampPct(segment.pct) }"
:data-test="`o-segmented-bar-segment-${segment.id}`"
/>
+169 -49
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "غير متضمن في ترخيصك",
"title": "لا يتضمن ترخيصك {feature}",
"description": "يمكن لمسؤول النظام إضافتها بتحديث الترخيص، أو تواصل مع فريق المبيعات للحصول على ترخيص يتضمنها.",
"thisFeature": "هذه الميزة",
"contactSales": "تواصل مع المبيعات",
"toastTitle": "غير متضمن في ترخيصك",
"toastMessage": "لا يتضمن ترخيصك: {features}. اطلب من مسؤول النظام تحديث الترخيص.",
"toastMessageGeneric": "لا يتضمن ترخيصك هذه الميزة. اطلب من مسؤول النظام تحديث الترخيص.",
"bannerMessage": "هذه الميزة مقفلة. تحتاج إلى ميزة {feature} في ترخيصك.",
"bannerMessageGeneric": "تحتاج هذه الصفحة إلى ميزة غير متضمنة في ترخيصك.",
"headerLocked": "مقفل: يتطلب {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "لا يوجد سجل بعد",
@@ -3420,22 +3407,6 @@
"contact_name": "اسم الاتصال",
"contact_email": "بريد إلكتروني للتواصل",
"environment_type": "نوع البيئة",
"features": "الميزات",
"features_all_unlocked": "يتيح ترخيصك جميع الميزات.",
"features_summary": "يتيح ترخيصك {ratio}. {free} ميزة مجانية متضمنة دائمًا. | يتيح ترخيصك {ratio}. {free} ميزات مجانية متضمنة دائمًا.",
"features_paid_ratio": "{unlocked} من {paid} ميزة مدفوعة | {unlocked} من {paid} ميزات مدفوعة",
"features_unlocked": "مفتوحة",
"features_missing": "غير متضمن في ترخيصك",
"features_request_access": "طلب الوصول",
"features_search": "بحث في {count} ميزة | بحث في جميع الميزات البالغ عددها {count}",
"features_filter_all": "الكل",
"features_filter_free": "مجاني",
"features_group_heading": "{heading} · {count}",
"features_no_match": "لا توجد ميزات تطابق بحثك",
"features_none": "لا توجد ميزات في هذه المجموعة",
"features_free_heading": "مجانية، ومتضمنة مع كل ترخيص",
"features_new": "جديد بعد ترخيصك",
"features_filter_new": "جديد",
"request_new_license": "طلب رخصة جديدة",
"add_new_license_key": "إضافة مفتاح ترخيص جديد",
"refresh_license_limits": "تحديث حدود الترخيص",
@@ -9915,7 +9886,7 @@
"alertsSuffix": "تنبيهات",
"viewingLabel": "— المشاهدة",
"timesSymbol": "×",
"activeAnomalies": "الشذوذات النشطة",
"activeAnomalies": "الحالات الشاذة النشطة",
"activeIncidents": "الحوادث النشطة",
"services": "الخدمات",
"recentEvents": "الأحداث الأخيرة",
@@ -9928,33 +9899,184 @@
"scrollLeft": "تمرير الخدمات لليسار",
"scrollRight": "تمرير الخدمات لليمين",
"untitledIncident": "حادث بلا عنوان",
"allClear": "جميع الأنظمة نظيفة",
"allClearDesc": "لا توجد شذوذات نشطة، أو حوادث، أو خدمات متدهورة في الفترة الزمنية المحددة. تبدو بنيتك التحتية صحية.",
"allClearDescScoped": "لا توجد حوادث مفتوحة أو شذوذات أو خدمات متدهورة في النطاق الزمني المحدد.",
"allClearPartial": "لا يوجد ما يُبلّغ عنه",
"allClearPartialDesc": "لا شيء يحتاج إلى انتباه. لم نتحقق من {areas} لأن ترخيصك لا يتضمن تلك الميزات.",
"areaAlerts": "التنبيهات",
"areaAnomalies": "كشف الحالات الشاذة",
"areaIncidents": "الحوادث",
"areaServices": "الخدمات",
"notMonitoredTitle": "لا شيء تحت المراقبة بعد",
"notMonitoredDesc": "بياناتك تصل، لكن لا يعمل عليها أي تنبيه أو فحص شذوذ. أضف واحدًا وستسرد النظرة العامة كل ما يحتاج إلى انتباه.",
"notMonitoredAlert": "إنشاء تنبيه",
"notMonitoredAlertDesc": "احصل على إشعار عندما يتجاوز استعلام حدًا معينًا",
"notMonitoredAnomaly": "إعداد كشف الشذوذ",
"notMonitoredAnomalyDesc": "اكتشف الأحجام غير المعتادة دون تحديد حد",
"emptyActionAlerts": "عرض قواعد التنبيه",
"emptyActionAlertsDesc": "مراجعة التنبيهات المكونة والعتبات",
"emptyActionLogs": "استكشف السجلات",
"emptyActionLogsDesc": "ابحث وفلتر تدفقات السجلات الخاصة بك",
"emptyActionTraces": "استكشف التتبعات",
"emptyActionTracesDesc": "فحص التتبع الموزع وزمن استجابة الخدمة",
"anomalyDetectedTitle": "تم اكتشاف شذوذ — {name}",
"anomalyDetectedDescription": "التدفق: {stream} · النقاط الشاذة: {count}",
"secondsAgo": "{count}s منذ",
"minutesAgo": "منذ {count}m",
"hoursAgo": "منذ {count}h",
"daysAgo": "منذ {count}d"
"daysAgo": "منذ {count}d",
"cards": {
"firingAlerts": "التنبيهات قيد الإطلاق",
"failingRules": "القواعد الفاشلة",
"openIncidents": "الحوادث المفتوحة",
"pipelineErrors": "أخطاء مسار المعالجة",
"pipelinesOf": "من {enabled} مفعّل",
"quietStreams": "التدفقات الهادئة",
"firingNone": "لا شيء قيد الإطلاق",
"noAlertRules": "لا توجد قواعد تنبيه",
"noEnabledRules": "لا توجد قواعد مفعّلة",
"failingNone": "لا شيء فاشل",
"failingNote": "حدث خطأ في آخر تقييم",
"incidentsNone": "لا شيء مفتوح",
"noPipelines": "لا توجد مسارات معالجة",
"noStreams": "لا توجد تدفقات",
"quietNote": "توقفت عن الكتابة خلال آخر 7 أيام",
"criticalUnit": "{count} في المستوى الحرج",
"deliveryFailures": "إخفاقات تسليم التنبيهات",
"deliveryNote": "تنبيهات أُطلقت لكن تعذّر إشعارها",
"oldestFiring": "أقدم تنبيه قيد الإطلاق منذ {age}"
},
"state": {
"tileForbidden": "لا يوجد وصول",
"tileLocked": "مقفل",
"tileError": "تعذّر التحميل",
"tileUnavailable": "غير متاح",
"sectionLocked": "متاح بترخيص المؤسسات",
"sectionForbidden": "لا يمكن لدورك رؤية هذه البيانات. اطلب من مسؤول المؤسسة منحك الوصول."
},
"time": {
"minutes": "{minutes} د",
"hoursMinutes": "{hours} س {minutes} د",
"daysHours": "{days} ي {hours} س"
},
"level": {
"critical": "حرج",
"warning": "تحذير",
"okFiring": "قيد الإطلاق"
},
"sections": {
"alerts": "التنبيهات",
"dataHealth": "صحة البيانات",
"alertsAndIncidents": "التنبيهات والحوادث",
"limitAndStorage": "حد الاستيعاب والتخزين",
"storage": "حجم البيانات",
"topStreams": "أعلى التدفقات حسب حجم البيانات"
},
"alerts": {
"firingFor": "قيد الإطلاق منذ",
"showMore": "عرض {count} تنبيه آخر قيد الإطلاق | عرض {count} تنبيهات أخرى قيد الإطلاق",
"emptyTitle": "لا توجد قواعد تنبيه بعد",
"emptyDesc": "تظهر القواعد التي تنشئها هنا، مع ما هو قيد الإطلاق حسب المستوى وما يفشل في التشغيل.",
"emptyAction": "إنشاء تنبيه",
"criticalLevel": "المستوى الحرج",
"warningLevel": "مستوى التحذير",
"rulesFailing": "القواعد الفاشلة",
"firingNow": "قيد الإطلاق الآن، الأقدم أولًا",
"noneFiring": "لا توجد تنبيهات قيد الإطلاق الآن",
"rulesActive": "{count} قاعدة نشطة | {count} قواعد نشطة",
"rulesFailingToEvaluate": "{count} قاعدة تفشل في التقييم | {count} قواعد تفشل في التقييم",
"subtitle": "{active} · {failing}",
"viewAll": "عرض كل التنبيهات",
"rulesEvaluated": "{count} قاعدة قيد التقييم | {count} قواعد قيد التقييم"
},
"dataHealth": {
"viewPipelines": "عرض مسارات المعالجة",
"noPipelines": "لا توجد مسارات معالجة بعد.",
"pipelinesErrored": "مسارات معالجة بها أخطاء",
"pipelinesNote": "مسارات معالجة ممكّنة بها خطأ",
"quiet": "تدفقات توقفت عن النشاط",
"quietNote": "توقفت عن الكتابة منذ ما بين ساعة و7 أيام",
"failing": "قواعد تنبيه تفشل في التقييم",
"failingNote": "نقاط عمياء، غير قيد الإطلاق",
"delivery": "فشل تسليم التنبيهات",
"deliveryNote": "تنبيهات أُطلقت لكن تعذّر إشعارها",
"schema": "تغييرات المخطط خلال 24 ساعة",
"schemaNote": "إصدارات مخطط جديدة عبر تدفقاتك",
"status": {
"needsAction": "يتطلب إجراءً",
"warning": "تحذير",
"healthy": "سليم",
"notSetUp": "غير مُعدّ",
"info": "معلومات"
},
"compaction": "مهام الضغط المعلّقة",
"compactionNote": "عبر أنواع التدفقات التي يمكنك رؤيتها",
"pipelinesActive": "من أصل {count} مسار معالجة نشط | من أصل {count} مسارات معالجة نشطة",
"subtitle": "هل تصل البيانات سليمة ويتم تسليمها؟"
},
"streams": {
"emptyTitle": "لا توجد تدفقات بعد",
"emptyDesc": "تظهر التدفقات هنا بمجرد بدء وصول البيانات.",
"startIngesting": "ابدأ الاستيعاب",
"allStreams": "كل التدفقات",
"shareOfTotal": "{percent}% من الإجمالي",
"shareUnderOne": "أقل من 1% من الإجمالي",
"subtitle": "أكبر التدفقات التي يمكنك الوصول إليها",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "فتح",
"openAria": {
"logs": "فتح السجلات",
"metrics": "فتح المقاييس",
"traces": "فتح التتبعات"
},
"logs": "السجلات",
"metrics": "المقاييس",
"traces": "التتبعات",
"streams": "التدفقات",
"events": "الأحداث",
"samples": "العينات",
"spans": "الامتدادات",
"stored": "حجم البيانات"
},
"storage": {
"emptyTitle": "لا توجد بيانات بعد",
"bySignal": "حجم البيانات حسب الإشارة",
"splitNote": "الحجم غير المضغوط عبر السجلات والمقاييس والتتبعات",
"used": "حجم البيانات"
},
"limit": {
"used": "المستوعب اليوم (UTC)",
"ofLimit": "من {limit}",
"daysOver": "أيام تجاوز الحد هذا الشهر",
"approachingLimit": "الاقتراب من الحد",
"overLimit": "تجاوز الحد",
"ofUnlimited": "من غير المحدود",
"unavailable": "حد الاستيعاب غير متاح",
"blockNote": "يُحظر البحث بعد تجاوز الحد بأكثر من {allowed} يوم | يُحظر البحث بعد تجاوز الحد بأكثر من {allowed} يوم",
"daysOverOf": "{days} من {allowed} مسموح",
"expiredLine": "انتهت صلاحية الترخيص",
"resets": "يُعاد التعيين عند 00:00 UTC، خلال {countdown}",
"subtitle": "يُحسب الحد اليومي حسب يوم UTC، وليس 24 ساعة متجددة"
},
"empty": {
"incidents": "لا توجد حوادث مفتوحة",
"anomalies": "لم يُكتشف أي شذوذ في النطاق الزمني المحدد",
"anomalyNoChecks": "لم يتم إعداد أي فحوصات للشذوذ",
"services": "لا توجد بيانات خدمة في النطاق الزمني المحدد",
"recentEvents": "لا توجد أحداث تنبيه في النطاق الزمني المحدد"
},
"stale": {
"message": "يتم عرض البيانات من {time}. فشل التحديث.",
"retry": "إعادة المحاولة"
},
"dataAsOf": "البيانات حتى {time}",
"status": {
"attention": "يتطلب الانتباه",
"checking": "جارٍ التحقق…",
"partial": "تعذّر تحميل بعض الفحوصات",
"clear": "لا شيء يتطلب الانتباه",
"items": {
"firing": "{count} تنبيه قيد الإطلاق | {count} تنبيه قيد الإطلاق",
"failing": "{count} قاعدة فاشلة | {count} قاعدة فاشلة",
"incidents": "{count} حادث مفتوح | {count} حادث مفتوح",
"pipelines": "{count} مسار معالجة به أخطاء | {count} مسار معالجة به أخطاء",
"quiet": "{count} تدفق صامت | {count} تدفق صامت"
},
"unmonitored": "لا تتم مراقبة أي شيء بعد"
},
"timeRange": {
"heading": "في النطاق الزمني المحدد"
}
},
"chatHistory": {
"title": "الدردشات",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "إعادة تشغيل {count} Collectors؟",
"restartMessage": "يُعاد تشغيل كل Collector عند رسالته التالية من الخادم. يتوقف جمع البيانات لفترة وجيزة على كل مضيف.",
"restartQueued": "طُلبت إعادة التشغيل لـ {count} Collector | طُلبت إعادة التشغيل لـ {count} Collectors",
"restartOffline": "هذا المجمّع غير متصل. أعد تشغيله بمجرد عودته.",
"restartUnsupported": "لا يقبل هذا الـ Collector أوامر إعادة التشغيل",
"systemSections": "الأقسام التي يديرها النظام",
"systemSectionsCaption": "امتداد OpAMP والقياس عن بُعد للخدمة، يضبطهما المشرف والخادم",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "يفتح محرر {config}. انشر إصدارًا مصححًا وغيّر هذا الأسطول إليه؛ وعندها تُستبدل عملية الطرح هذه.",
"confirmResume": "تتابع عملية الطرح نفسها من المرحلة {n}.",
"confirmRollback": "يبدأ فورًا عملية طرح للعودة إلى الإصدار المستقر، {config} v{version}. لا تتلقى Collectors الموجودة عليه شيئًا.",
"confirmRollbackNoStable": "لا يحتوي هذا الأسطول على إصدار مستقر، لذا ينتهي الطرح وتتوقف مجمّعاته عن الجمع حتى تنشر مرة أخرى.",
"confirmRollbackNoStable": "يبدأ فورًا عملية طرح للعودة إلى الإصدار المستقر.",
"confirmSkip": "يستبعد {count} Collector فاشلًا ويتابع. يُوسَم بأنه مستبعد ويُضمَّن مجددًا في عملية الطرح التالية. | يستبعد {count} Collectors فاشلة ويتابع. تُوسَم بأنها مستبعدة وتُضمَّن مجددًا في عملية الطرح التالية.",
"confirmed": "تم التأكيد",
"eventConverged": "تقاربت المرحلة {n}",
@@ -29358,7 +29479,6 @@
"phaseN": "المرحلة {n}",
"phaseTimedOut": "انتهت المهلة",
"phaseTimeoutNote": "المرحلة التي لا تؤكد ولا ترفض خلال {minutes} د توقف عملية الطرح (إعداد الأسطول)",
"phaseTimeoutNoteSeconds": "المرحلة التي لا تؤكد ولا ترفض خلال {minutes} دقيقة و{seconds} ثانية توقف الطرح (إعداد الأسطول)",
"phaseVerifying": "قيد التحقق",
"phases": "المراحل",
"planEmpty": "لا يوجد أي جامع متصل، لذا ينتظر الطرح حتى يتصل أحدها.",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "وراثة ({value})",
"offlineHeartbeats": "غير متصل بعد نبضات فائتة",
"offlineHeartbeatsHelp": "يُعد الـ Collector غير متصل بعد هذا العدد من فواصل النبضات دون رسالة",
"offlineRatio": "نسبة عدم الاتصال (%)",
"offlineRatioHelp": "يوقف المرحلة عندما تتجاوز نسبة مجمّعاتها غير المتصلة هذه النسبة",
"offlineRatio": "حدث نسبة عدم الاتصال (%)",
"offlineRatioHelp": "يسجّل حدثًا للأسطول عندما تزيد نسبة Collectors غير المتصلة عن هذه النسبة",
"outOfRange": "أدخل رقمًا من {min} إلى {max}",
"phaseTimeout": "مهلة المرحلة (ثوانٍ)",
"phaseTimeoutHelp": "المرحلة التي لا تؤكد ولا ترفض خلال هذه المدة توقف عملية الطرح",
+169 -49
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Nicht in Ihrer Lizenz",
"title": "{feature} ist nicht in Ihrer Lizenz enthalten",
"description": "Ihr Administrator kann sie durch Aktualisieren der Lizenz hinzufügen, oder kontaktieren Sie den Vertrieb, um eine Lizenz zu erhalten, die sie enthält.",
"thisFeature": "Diese Funktion",
"contactSales": "Vertrieb kontaktieren",
"toastTitle": "Nicht in Ihrer Lizenz enthalten",
"toastMessage": "Ihre Lizenz enthält nicht: {features}. Bitten Sie Ihren Administrator, die Lizenz zu aktualisieren.",
"toastMessageGeneric": "Ihre Lizenz enthält diese Funktion nicht. Bitten Sie Ihren Administrator, die Lizenz zu aktualisieren.",
"bannerMessage": "Diese Funktion ist gesperrt. Sie erfordert die Funktion {feature} in Ihrer Lizenz.",
"bannerMessageGeneric": "Diese Seite benötigt eine Funktion, die nicht in Ihrer Lizenz enthalten ist.",
"headerLocked": "Gesperrt: benötigt {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Noch kein Verlauf",
@@ -3420,22 +3407,6 @@
"contact_name": "Name des Ansprechpartners",
"contact_email": "Kontakt-E-Mail",
"environment_type": "Art der Umgebung",
"features": "Funktionen",
"features_all_unlocked": "Ihre Lizenz schaltet alle Funktionen frei.",
"features_summary": "Ihre Lizenz schaltet {ratio} frei. {free} kostenlose Funktion ist immer enthalten. | Ihre Lizenz schaltet {ratio} frei. {free} kostenlose Funktionen sind immer enthalten.",
"features_paid_ratio": "{unlocked} von {paid} kostenpflichtigen Funktion | {unlocked} von {paid} kostenpflichtigen Funktionen",
"features_unlocked": "Freigeschaltet",
"features_missing": "Nicht in Ihrer Lizenz",
"features_request_access": "Zugriff anfordern",
"features_search": "{count} Funktion durchsuchen | Alle {count} Funktionen durchsuchen",
"features_filter_all": "Alle",
"features_filter_free": "Kostenlos",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Keine Funktionen entsprechen Ihrer Suche",
"features_none": "Keine Funktionen in dieser Gruppe",
"features_free_heading": "Kostenlos, in jeder Lizenz enthalten",
"features_new": "Neu seit Ihrer Lizenz",
"features_filter_new": "Neu",
"request_new_license": "Neue Lizenz anfordern",
"add_new_license_key": "Neuen Lizenzschlüssel hinzufügen",
"refresh_license_limits": "Lizenzlimits aktualisieren",
@@ -9918,7 +9889,7 @@
"activeAnomalies": "Aktive Anomalien",
"activeIncidents": "Aktive Vorfälle",
"services": "Dienstleistungen",
"recentEvents": "Aktuelle Ereignisse",
"recentEvents": "Letzte Ereignisse",
"investigate": "Untersuchen →",
"viewAll": "Alles ansehen",
"showingOf": "Es werden {shown} von {total} angezeigt",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Dienste nach links scrollen",
"scrollRight": "Dienste nach rechts scrollen",
"untitledIncident": "Unbenannter Vorfall",
"allClear": "Alles klar",
"allClearDesc": "Keine aktiven Anomalien, Vorfälle oder beeinträchtigte Dienste im ausgewählten Zeitfenster.",
"allClearDescScoped": "Keine offenen Vorfälle, Anomalien oder beeinträchtigten Dienste im ausgewählten Zeitraum.",
"allClearPartial": "Nichts zu melden",
"allClearPartialDesc": "Nichts erfordert Aufmerksamkeit. Wir haben {areas} nicht geprüft, da Ihre Lizenz diese Funktionen nicht umfasst.",
"areaAlerts": "Alarme",
"areaAnomalies": "Anomalieerkennung",
"areaIncidents": "Vorfälle",
"areaServices": "Services",
"notMonitoredTitle": "Noch wird nichts überwacht",
"notMonitoredDesc": "Ihre Daten treffen ein, aber es läuft keine Alarm- oder Anomalieprüfung darauf. Fügen Sie eine hinzu, und die Übersicht listet alles auf, was Aufmerksamkeit erfordert.",
"notMonitoredAlert": "Alarm erstellen",
"notMonitoredAlertDesc": "Benachrichtigung erhalten, wenn eine Abfrage einen Schwellenwert überschreitet",
"notMonitoredAnomaly": "Anomalieerkennung einrichten",
"notMonitoredAnomalyDesc": "Ungewöhnliches Volumen markieren, ohne einen Schwellenwert festzulegen",
"emptyActionAlerts": "Alarmregeln anzeigen",
"emptyActionAlertsDesc": "Konfigurierte Alarme und Schwellenwerte überprüfen",
"emptyActionLogs": "Logs erkunden",
"emptyActionLogsDesc": "Suchen und filtern Sie Ihre Log-Streams",
"emptyActionTraces": "Traces erkunden",
"emptyActionTracesDesc": "Untersuchen Sie verteilte Traces und Service-Latenz",
"anomalyDetectedTitle": "Anomalie erkannt — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} anomale Punkte",
"secondsAgo": "{count}s her",
"minutesAgo": "{count}m her",
"hoursAgo": "{count}h her",
"daysAgo": "{count}d her"
"daysAgo": "{count}d her",
"cards": {
"firingAlerts": "Ausgelöste Alarme",
"failingRules": "Fehlerhafte Regeln",
"openIncidents": "Offene Vorfälle",
"pipelineErrors": "Pipeline-Fehler",
"pipelinesOf": "von {enabled} aktiviert",
"quietStreams": "Inaktive Streams",
"firingNone": "Keine ausgelöst",
"noAlertRules": "Keine Alarmregeln",
"noEnabledRules": "Keine aktivierten Regeln",
"failingNone": "Keine fehlerhaft",
"failingNote": "Letzte Auswertung fehlgeschlagen",
"incidentsNone": "Keine offen",
"noPipelines": "Keine Pipelines",
"noStreams": "Keine Streams",
"quietNote": "Keine Schreibvorgänge in den letzten 7 Tagen",
"criticalUnit": "{count} auf kritischer Stufe",
"deliveryFailures": "Fehler bei der Alarmzustellung",
"deliveryNote": "Alarme, die ausgelöst wurden, aber nicht benachrichtigen konnten",
"oldestFiring": "Ältester ausgelöst seit {age}"
},
"state": {
"tileForbidden": "Kein Zugriff",
"tileLocked": "Gesperrt",
"tileError": "Laden fehlgeschlagen",
"tileUnavailable": "Nicht verfügbar",
"sectionLocked": "Verfügbar mit einer Enterprise-Lizenz",
"sectionForbidden": "Ihre Rolle kann diese Daten nicht sehen. Bitten Sie einen Organisations-Administrator um Zugriff."
},
"time": {
"minutes": "{minutes} Min.",
"hoursMinutes": "{hours} Std. {minutes} Min.",
"daysHours": "{days} T. {hours} Std."
},
"level": {
"critical": "Kritisch",
"warning": "Warnung",
"okFiring": "Ausgelöst"
},
"sections": {
"alerts": "Alarme",
"dataHealth": "Datenzustand",
"alertsAndIncidents": "Alarme und Vorfälle",
"limitAndStorage": "Erfassungslimit und Speicher",
"storage": "Datengröße",
"topStreams": "Top-Streams nach Datengröße"
},
"alerts": {
"firingFor": "Ausgelöst seit",
"showMore": "{count} weiteren ausgelösten Alarm anzeigen | {count} weitere ausgelöste Alarme anzeigen",
"emptyTitle": "Noch keine Alarmregeln",
"emptyDesc": "Erstellte Regeln erscheinen hier – mit dem, was nach Stufe ausgelöst wird, und dem, was nicht ausgeführt werden kann.",
"emptyAction": "Alarm erstellen",
"criticalLevel": "Kritische Stufe",
"warningLevel": "Warnstufe",
"rulesFailing": "Regeln mit Fehlern",
"firingNow": "Aktuell ausgelöst, älteste zuerst",
"noneFiring": "Derzeit keine ausgelösten Alarme",
"rulesActive": "{count} Regel aktiv | {count} Regeln aktiv",
"rulesFailingToEvaluate": "{count} Auswertung fehlgeschlagen | {count} Auswertungen fehlgeschlagen",
"subtitle": "{active} · {failing}",
"viewAll": "Alle Alarme anzeigen",
"rulesEvaluated": "{count} Regel wird ausgewertet | {count} Regeln werden ausgewertet"
},
"dataHealth": {
"viewPipelines": "Pipelines anzeigen",
"noPipelines": "Noch keine Pipelines.",
"pipelinesErrored": "Pipelines mit Fehlern",
"pipelinesNote": "Aktivierte Pipelines mit einem Fehler",
"quiet": "Streams ohne Datenfluss",
"quietNote": "Seit 1 Stunde bis 7 Tagen keine Schreibaktivität",
"failing": "Alarmregeln mit Auswertungsfehlern",
"failingNote": "Blinde Flecken, nicht ausgelöst",
"delivery": "Fehler bei der Alarmzustellung",
"deliveryNote": "Alarme, die ausgelöst wurden, aber keine Benachrichtigung senden konnten",
"schema": "Schemaänderungen in 24 Std.",
"schemaNote": "Neue Schemaversionen in Ihren Streams",
"status": {
"needsAction": "Aktion erforderlich",
"warning": "Warnung",
"healthy": "Fehlerfrei",
"notSetUp": "Nicht eingerichtet",
"info": "Info"
},
"compaction": "Kompaktierungsjobs ausstehend",
"compactionNote": "Über alle Stream-Typen, die Sie sehen können",
"pipelinesActive": "Von {count} aktiven Pipeline | Von {count} aktiven Pipelines",
"subtitle": "Kommen Daten an, sind sie intakt und werden sie zugestellt?"
},
"streams": {
"emptyTitle": "Noch keine Streams",
"emptyDesc": "Streams werden hier angezeigt, sobald Daten eintreffen.",
"startIngesting": "Datenerfassung starten",
"allStreams": "Alle Streams",
"shareOfTotal": "{percent} % des Gesamtwerts",
"shareUnderOne": "Unter 1 % des Gesamtwerts",
"subtitle": "Größte Streams, auf die Sie Zugriff haben",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Öffnen",
"openAria": {
"logs": "Logs öffnen",
"metrics": "Metriken öffnen",
"traces": "Traces öffnen"
},
"logs": "Logs",
"metrics": "Metriken",
"traces": "Traces",
"streams": "Streams",
"events": "Ereignisse",
"samples": "Samples",
"spans": "Spans",
"stored": "Datengröße"
},
"storage": {
"emptyTitle": "Noch keine Daten",
"bySignal": "Datengröße nach Signaltyp",
"splitNote": "Unkomprimierte Größe für Logs, Metriken und Traces",
"used": "Datengröße"
},
"limit": {
"used": "Heute erfasst (UTC)",
"ofLimit": "von {limit}",
"daysOver": "Tage über dem Limit in diesem Monat",
"approachingLimit": "Nahe am Limit",
"overLimit": "Limit überschritten",
"ofUnlimited": "von unbegrenzt",
"unavailable": "Erfassungslimit nicht verfügbar",
"blockNote": "Die Suche wird blockiert, sobald das Limit um mehr als {allowed} Tag überschritten ist | Die Suche wird blockiert, sobald das Limit um mehr als {allowed} Tage überschritten ist",
"daysOverOf": "{days} von {allowed} erlaubt",
"expiredLine": "Lizenz abgelaufen",
"resets": "Zurücksetzung um 00:00 UTC, in {countdown}",
"subtitle": "Das Tageslimit bezieht sich auf den UTC-Tag, nicht auf ein rollierendes 24h-Intervall"
},
"empty": {
"incidents": "Keine offenen Vorfälle",
"anomalies": "Keine Anomalien im ausgewählten Zeitraum erkannt",
"anomalyNoChecks": "Es sind keine Anomalieprüfungen eingerichtet",
"services": "Keine Servicedaten im ausgewählten Zeitraum",
"recentEvents": "Keine Alarmereignisse im ausgewählten Zeitraum"
},
"stale": {
"message": "Daten von {time} werden angezeigt. Aktualisierung fehlgeschlagen.",
"retry": "Wiederholen"
},
"dataAsOf": "Datenstand: {time}",
"status": {
"attention": "Erfordert Aufmerksamkeit",
"checking": "Wird geprüft…",
"partial": "Einige Prüfungen konnten nicht geladen werden",
"clear": "Nichts erfordert Aufmerksamkeit",
"items": {
"firing": "{count} Alarm ausgelöst | {count} Alarme ausgelöst",
"failing": "{count} Regel schlägt fehl | {count} Regeln schlagen fehl",
"incidents": "{count} offener Vorfall | {count} offene Vorfälle",
"pipelines": "{count} Pipeline mit Fehlern | {count} Pipelines mit Fehlern",
"quiet": "{count} inaktiver Stream | {count} inaktive Streams"
},
"unmonitored": "Es wird noch nichts überwacht"
},
"timeRange": {
"heading": "Im ausgewählten Zeitraum"
}
},
"chatHistory": {
"title": "Chats",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "{count} Collector neu starten?",
"restartMessage": "Jeder Collector startet bei der nächsten Nachricht vom Server neu. Die Datenerfassung pausiert auf jedem Host kurz.",
"restartQueued": "Neustart für {count} Collector angefordert | Neustart für {count} Collector angefordert",
"restartOffline": "Dieser Collector ist offline. Starten Sie ihn neu, sobald er wieder verfügbar ist.",
"restartUnsupported": "Dieser Collector akzeptiert keine Neustart-Befehle",
"systemSections": "Systemverwaltete Abschnitte",
"systemSectionsCaption": "OpAMP-Erweiterung und Dienst-Telemetrie, vom Supervisor und vom Server festgelegt",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Öffnet den Editor für {config}. Veröffentlichen Sie eine korrigierte Version und stellen Sie dieses Fleet darauf um; dieses Rollout wird dann abgelöst.",
"confirmResume": "Dasselbe Rollout wird ab Phase {n} fortgesetzt.",
"confirmRollback": "Startet sofort ein Rollout zurück auf die stabile Version, {config} v{version}. Collector, die sie bereits haben, erhalten nichts.",
"confirmRollbackNoStable": "Diese Fleet hat keine stabile Version, daher endet das Rollout und ihre Collectors stellen die Erfassung ein, bis Sie erneut bereitstellen.",
"confirmRollbackNoStable": "Startet sofort ein Rollout zurück auf die stabile Version.",
"confirmSkip": "Schließt {count} fehlerhaften Collector aus und fährt fort. Er wird als ausgeschlossen markiert und im nächsten Rollout wieder einbezogen. | Schließt {count} fehlerhafte Collector aus und fährt fort. Sie werden als ausgeschlossen markiert und im nächsten Rollout wieder einbezogen.",
"confirmed": "Bestätigt",
"eventConverged": "Phase {n} konvergiert",
@@ -29358,7 +29479,6 @@
"phaseN": "Phase {n}",
"phaseTimedOut": "Zeitüberschreitung",
"phaseTimeoutNote": "Eine Phase, die innerhalb von {minutes} Min. weder bestätigt noch ablehnt, hält das Rollout an (Fleet-Einstellung)",
"phaseTimeoutNoteSeconds": "Eine Phase, die innerhalb von {minutes} Min. {seconds} Sek. weder bestätigt noch ablehnt, stoppt das Rollout (Fleet-Einstellung)",
"phaseVerifying": "Wird überprüft",
"phases": "Phasen",
"planEmpty": "Kein Collector ist online, daher wartet der Rollout, bis sich einer verbindet",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Erben ({value})",
"offlineHeartbeats": "Offline nach verpassten Heartbeats",
"offlineHeartbeatsHelp": "Ein Collector gilt als offline, wenn so viele Heartbeat-Intervalle ohne Nachricht vergangen sind",
"offlineRatio": "Offline-Anteil (%)",
"offlineRatioHelp": "Stoppt die Phase, wenn mehr als dieser Anteil ihrer Collectors offline ist",
"offlineRatio": "Ereignis bei Offline-Anteil (%)",
"offlineRatioHelp": "Zeichnet ein Fleet-Ereignis auf, wenn mehr als dieser Anteil der Collector offline ist",
"outOfRange": "Eine Zahl von {min} bis {max} eingeben",
"phaseTimeout": "Phasen-Zeitlimit (Sekunden)",
"phaseTimeoutHelp": "Ein Rollout, dessen Phase in dieser Zeit weder bestätigt noch ablehnt, wird angehalten",
+169 -18
View File
@@ -9913,10 +9913,10 @@
"alertsSuffix": "alerts",
"viewingLabel": "— viewing",
"timesSymbol": "×",
"activeAnomalies": "Active Anomalies",
"activeIncidents": "Active Incidents",
"activeAnomalies": "Active anomalies",
"activeIncidents": "Active incidents",
"services": "Services",
"recentEvents": "Recent Events",
"recentEvents": "Recent events",
"investigate": "Investigate →",
"viewAll": "View all",
"showingOf": "Showing {shown} of {total}",
@@ -9925,34 +9925,185 @@
"colReqs": "Reqs",
"scrollLeft": "Scroll services left",
"scrollRight": "Scroll services right",
"untitledIncident": "Untitled Incident",
"allClear": "All systems clear",
"allClearDesc": "No active anomalies, incidents, or degraded services in the selected time window. Your infrastructure looks healthy.",
"allClearDescScoped": "No open incidents, anomalies or degraded services in the selected time range.",
"allClearPartial": "Nothing to report",
"allClearPartialDesc": "Nothing needs attention. We did not check {areas}, because your license does not include those features.",
"areaAlerts": "Alerts",
"areaAnomalies": "Anomaly detection",
"areaIncidents": "Incidents",
"areaServices": "Services",
"untitledIncident": "Untitled incident",
"notMonitoredTitle": "Nothing is watched yet",
"notMonitoredDesc": "Your data is arriving, but no alert or anomaly check runs on it. Add one and Overview lists anything that needs attention.",
"notMonitoredAlert": "Create an alert",
"notMonitoredAlertDesc": "Get notified when a query crosses a threshold",
"notMonitoredAnomaly": "Set up anomaly detection",
"notMonitoredAnomalyDesc": "Flag unusual volume without picking a threshold",
"emptyActionAlerts": "View alert rules",
"emptyActionAlertsDesc": "Review configured alerts and thresholds",
"emptyActionLogs": "Explore logs",
"emptyActionLogsDesc": "Search and filter your log streams",
"emptyActionTraces": "Explore traces",
"emptyActionTracesDesc": "Inspect distributed traces and service latency",
"anomalyDetectedTitle": "Anomaly detected — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} anomalous point(s)",
"secondsAgo": "{count}s ago",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
"daysAgo": "{count}d ago",
"cards": {
"firingAlerts": "Firing alerts",
"failingRules": "Failing rules",
"openIncidents": "Open incidents",
"pipelineErrors": "Pipeline errors",
"pipelinesOf": "of {enabled} enabled",
"quietStreams": "Quiet streams",
"firingNone": "None firing",
"noAlertRules": "No alert rules",
"noEnabledRules": "No enabled rules",
"failingNone": "None failing",
"failingNote": "Last evaluation errored",
"incidentsNone": "None open",
"noPipelines": "No pipelines",
"noStreams": "No streams",
"quietNote": "Stopped writing in the last 7 days",
"criticalUnit": "{count} at critical level",
"deliveryFailures": "Alert delivery failures",
"deliveryNote": "Alerts that fired but could not notify",
"oldestFiring": "Oldest firing for {age}"
},
"state": {
"tileForbidden": "No access",
"tileLocked": "Locked",
"tileError": "Could not load",
"tileUnavailable": "Not available",
"sectionLocked": "Available with an enterprise license",
"sectionForbidden": "Your role cannot see this data. Ask an org admin for access."
},
"time": {
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m",
"daysHours": "{days}d {hours}h"
},
"level": {
"critical": "Critical",
"warning": "Warning",
"okFiring": "Firing"
},
"sections": {
"alerts": "Alerts",
"dataHealth": "Data health",
"alertsAndIncidents": "Alerts and incidents",
"limitAndStorage": "Ingest limit and storage",
"storage": "Data size",
"topStreams": "Top streams by data size"
},
"alerts": {
"firingFor": "Firing for",
"showMore": "Show {count} more firing alert | Show {count} more firing alerts",
"emptyTitle": "No alert rules yet",
"emptyDesc": "Rules you create show here, with what is firing by level and what is failing to run.",
"emptyAction": "Create an alert",
"criticalLevel": "Critical level",
"warningLevel": "Warning level",
"rulesFailing": "Rules failing",
"firingNow": "Firing now, oldest first",
"noneFiring": "No alerts firing right now",
"rulesActive": "{count} rule active | {count} rules active",
"rulesFailingToEvaluate": "{count} failing to evaluate | {count} failing to evaluate",
"subtitle": "{active} · {failing}",
"viewAll": "View all alerts",
"rulesEvaluated": "{count} rule is being evaluated | {count} rules are being evaluated"
},
"dataHealth": {
"viewPipelines": "View pipelines",
"noPipelines": "No pipelines yet.",
"pipelinesErrored": "Pipelines with errors",
"pipelinesNote": "Enabled pipelines with an error",
"quiet": "Streams gone quiet",
"quietNote": "Stopped writing between an hour and 7 days ago",
"failing": "Alert rules failing to evaluate",
"failingNote": "Blind spots, not firing",
"delivery": "Alert delivery failures",
"deliveryNote": "Alerts that fired but could not notify",
"schema": "Schema changes in 24h",
"schemaNote": "New schema versions across your streams",
"status": {
"needsAction": "Needs action",
"warning": "Warning",
"healthy": "Healthy",
"notSetUp": "Not set up",
"info": "Info"
},
"compaction": "Compaction jobs pending",
"compactionNote": "Across stream types you can see",
"pipelinesActive": "Of {count} active pipeline | Of {count} active pipelines",
"subtitle": "Is data arriving, intact and delivered?"
},
"streams": {
"emptyTitle": "No streams yet",
"emptyDesc": "Streams appear here once data starts arriving.",
"startIngesting": "Start ingesting",
"allStreams": "All streams",
"shareOfTotal": "{percent}% of total",
"shareUnderOne": "Under 1% of total",
"subtitle": "Largest streams you can access",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Open",
"openAria": {
"logs": "Open logs",
"metrics": "Open metrics",
"traces": "Open traces"
},
"logs": "Logs",
"metrics": "Metrics",
"traces": "Traces",
"streams": "Streams",
"events": "Events",
"samples": "Samples",
"spans": "Spans",
"stored": "Data size"
},
"storage": {
"emptyTitle": "No data yet",
"bySignal": "Data size by signal",
"splitNote": "Uncompressed size across logs, metrics and traces",
"used": "Data size"
},
"limit": {
"used": "Ingested today (UTC)",
"ofLimit": "of {limit}",
"daysOver": "Days over limit this month",
"approachingLimit": "Approaching limit",
"overLimit": "Over limit",
"ofUnlimited": "of unlimited",
"unavailable": "Ingest limit unavailable",
"blockNote": "Search is blocked after more than {allowed} day over the limit | Search is blocked after more than {allowed} days over the limit",
"daysOverOf": "{days} of {allowed} allowed",
"expiredLine": "License expired",
"resets": "Resets at 00:00 UTC, in {countdown}",
"subtitle": "Daily limit counts the UTC day, not a rolling 24h"
},
"empty": {
"incidents": "No open incidents",
"anomalies": "No anomalies detected in the selected time range",
"anomalyNoChecks": "No anomaly checks are set up",
"services": "No service data in the selected time range",
"recentEvents": "No alert events in the selected time range"
},
"stale": {
"message": "Showing data from {time}. Refresh failed.",
"retry": "Retry"
},
"dataAsOf": "Data as of {time}",
"status": {
"attention": "Needs attention",
"checking": "Checking…",
"partial": "Some checks could not load",
"clear": "Nothing needs attention",
"items": {
"firing": "{count} alert firing | {count} alerts firing",
"failing": "{count} rule failing | {count} rules failing",
"incidents": "{count} open incident | {count} open incidents",
"pipelines": "{count} pipeline with errors | {count} pipelines with errors",
"quiet": "{count} quiet stream | {count} quiet streams"
},
"unmonitored": "Nothing is being monitored yet"
},
"timeRange": {
"heading": "In the selected time range"
}
},
"chatHistory": {
"title": "Chats",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "No incluido en tu licencia",
"title": "{feature} no se incluye en tu licencia",
"description": "Tu administrador puede añadirlo actualizando la licencia, o contacta con ventas para obtener una licencia que lo incluya.",
"thisFeature": "Esta función",
"contactSales": "Contactar con ventas",
"toastTitle": "No incluido en tu licencia",
"toastMessage": "Tu licencia no incluye: {features}. Pide a tu administrador que actualice la licencia.",
"toastMessageGeneric": "Tu licencia no incluye esta función. Pide a tu administrador que actualice la licencia.",
"bannerMessage": "Esta función está bloqueada. Necesita la función {feature} en tu licencia.",
"bannerMessageGeneric": "Esta página necesita una función que no está en tu licencia.",
"headerLocked": "Bloqueado: necesita {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Aún no hay historial",
@@ -3420,22 +3407,6 @@
"contact_name": "Nombre de contacto",
"contact_email": "Correo electrónico de contacto",
"environment_type": "Tipo de entorno",
"features": "Funciones",
"features_all_unlocked": "Tu licencia desbloquea todas las funciones.",
"features_summary": "Tu licencia desbloquea {ratio}. {free} función gratuita siempre está incluida. | Tu licencia desbloquea {ratio}. {free} funciones gratuitas siempre están incluidas.",
"features_paid_ratio": "{unlocked} de {paid} función de pago | {unlocked} de {paid} funciones de pago",
"features_unlocked": "Desbloqueadas",
"features_missing": "No incluidas en tu licencia",
"features_request_access": "Solicitar acceso",
"features_search": "Buscar {count} función | Buscar las {count} funciones",
"features_filter_all": "Todas",
"features_filter_free": "Gratuitas",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Ninguna función coincide con tu búsqueda",
"features_none": "No hay funciones en este grupo",
"features_free_heading": "Gratuitas, incluidas con todas las licencias",
"features_new": "Nuevas después de tu licencia",
"features_filter_new": "Nuevas",
"request_new_license": "Solicitar una nueva licencia",
"add_new_license_key": "Agregar nueva clave de licencia",
"refresh_license_limits": "Actualizar los límites de la licencia",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Desplazar servicios a la izquierda",
"scrollRight": "Desplazar servicios a la derecha",
"untitledIncident": "Incidente sin título",
"allClear": "Todo despejado",
"allClearDesc": "No hay anomalías, incidentes ni servicios degradados activos en la ventana de tiempo seleccionada.",
"allClearDescScoped": "No hay incidentes abiertos, anomalías ni servicios degradados en el intervalo de tiempo seleccionado.",
"allClearPartial": "Nada que informar",
"allClearPartialDesc": "Nada requiere atención. No hemos comprobado {areas} porque tu licencia no incluye esas funcionalidades.",
"areaAlerts": "Alertas",
"areaAnomalies": "Detección de anomalías",
"areaIncidents": "Incidentes",
"areaServices": "Servicios",
"notMonitoredTitle": "Todavía no se monitoriza nada",
"notMonitoredDesc": "Tus datos están llegando, pero no se ejecuta ninguna alerta ni comprobación de anomalías sobre ellos. Añade una y la Vista general mostrará todo lo que requiera atención.",
"notMonitoredAlert": "Crear una alerta",
"notMonitoredAlertDesc": "Recibe una notificación cuando una consulta supere un umbral",
"notMonitoredAnomaly": "Configurar la detección de anomalías",
"notMonitoredAnomalyDesc": "Detecta volúmenes inusuales sin necesidad de elegir un umbral",
"emptyActionAlerts": "Ver reglas de alertas",
"emptyActionAlertsDesc": "Revise las alertas y los umbrales configurados",
"emptyActionLogs": "Explorar logs",
"emptyActionLogsDesc": "Busque y filtre sus streams de logs",
"emptyActionTraces": "Explora las trazas",
"emptyActionTracesDesc": "Inspecciona trazas distribuidas y la latencia del servicio",
"anomalyDetectedTitle": "Anomalía detectada — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} punto(s) anómalo(s)",
"secondsAgo": "hace {count}s",
"minutesAgo": "hace {count}m",
"hoursAgo": "Hace {count} h",
"daysAgo": "Hace {count} d"
"daysAgo": "Hace {count} d",
"cards": {
"firingAlerts": "Alertas disparadas",
"failingRules": "Reglas con errores",
"openIncidents": "Incidentes abiertos",
"pipelineErrors": "Errores de pipeline",
"pipelinesOf": "de {enabled} habilitados",
"quietStreams": "Streams sin actividad",
"firingNone": "Ninguna disparada",
"noAlertRules": "Sin reglas de alerta",
"noEnabledRules": "Sin reglas habilitadas",
"failingNone": "Ninguna con errores",
"failingNote": "La última evaluación falló",
"incidentsNone": "Ninguno abierto",
"noPipelines": "Sin pipelines",
"noStreams": "Sin streams",
"quietNote": "Dejaron de recibir datos en los últimos 7 días",
"criticalUnit": "{count} en nivel crítico",
"deliveryFailures": "Fallos de entrega de alertas",
"deliveryNote": "Alertas que se dispararon pero no pudieron notificar",
"oldestFiring": "La más antigua disparada desde hace {age}"
},
"state": {
"tileForbidden": "Sin acceso",
"tileLocked": "Bloqueado",
"tileError": "No se pudo cargar",
"tileUnavailable": "No disponible",
"sectionLocked": "Disponible con una licencia enterprise",
"sectionForbidden": "Tu rol no puede ver estos datos. Solicita acceso a un administrador de la organización."
},
"time": {
"minutes": "{minutes} min",
"hoursMinutes": "{hours} h {minutes} min",
"daysHours": "{days} d {hours} h"
},
"level": {
"critical": "Crítico",
"warning": "Advertencia",
"okFiring": "Disparada"
},
"sections": {
"alerts": "Alertas",
"dataHealth": "Salud de los datos",
"alertsAndIncidents": "Alertas e incidentes",
"limitAndStorage": "Límite de ingesta y almacenamiento",
"storage": "Tamaño de los datos",
"topStreams": "Streams principales por tamaño de datos"
},
"alerts": {
"firingFor": "Disparada desde hace",
"showMore": "Mostrar {count} alerta disparada más | Mostrar {count} alertas disparadas más",
"emptyTitle": "Aún no hay reglas de alerta",
"emptyDesc": "Las reglas que crees aparecerán aquí, con lo que está disparado por nivel y lo que está fallando al ejecutarse.",
"emptyAction": "Crear una alerta",
"criticalLevel": "Nivel crítico",
"warningLevel": "Nivel de advertencia",
"rulesFailing": "Reglas con errores",
"firingNow": "Disparadas ahora, de más antigua a más reciente",
"noneFiring": "No hay alertas disparadas ahora mismo",
"rulesActive": "{count} regla activa | {count} reglas activas",
"rulesFailingToEvaluate": "{count} con error de evaluación | {count} con error de evaluación",
"subtitle": "{active} · {failing}",
"viewAll": "Ver todas las alertas",
"rulesEvaluated": "{count} regla se está evaluando | {count} reglas se están evaluando"
},
"dataHealth": {
"viewPipelines": "Ver pipelines",
"noPipelines": "Aún no hay pipelines.",
"pipelinesErrored": "Pipelines con errores",
"pipelinesNote": "Pipelines habilitados con algún error",
"quiet": "Streams inactivos",
"quietNote": "Dejaron de escribir entre hace una hora y 7 días",
"failing": "Reglas de alerta con error de evaluación",
"failingNote": "Puntos ciegos, sin dispararse",
"delivery": "Fallos en la entrega de alertas",
"deliveryNote": "Alertas que se dispararon pero no pudieron notificar",
"schema": "Cambios de esquema en 24 h",
"schemaNote": "Nuevas versiones de esquema en tus streams",
"status": {
"needsAction": "Requiere acción",
"warning": "Advertencia",
"healthy": "Correcto",
"notSetUp": "Sin configurar",
"info": "Información"
},
"compaction": "Trabajos de compactación pendientes",
"compactionNote": "En todos los tipos de stream que puedes ver",
"pipelinesActive": "De {count} pipeline activo | De {count} pipelines activos",
"subtitle": "¿Llegan los datos, se mantienen íntegros y se entregan?"
},
"streams": {
"emptyTitle": "Aún no hay streams",
"emptyDesc": "Los streams aparecerán aquí cuando empiecen a llegar datos.",
"startIngesting": "Empezar a ingerir",
"allStreams": "Todos los streams",
"shareOfTotal": "{percent}% del total",
"shareUnderOne": "Menos del 1% del total",
"subtitle": "Los streams más grandes a los que tienes acceso",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Abrir",
"openAria": {
"logs": "Abrir logs",
"metrics": "Abrir métricas",
"traces": "Abrir trazas"
},
"logs": "Logs",
"metrics": "Métricas",
"traces": "Trazas",
"streams": "Streams",
"events": "Eventos",
"samples": "Muestras",
"spans": "Spans",
"stored": "Tamaño de datos"
},
"storage": {
"emptyTitle": "Aún no hay datos",
"bySignal": "Tamaño de datos por señal",
"splitNote": "Tamaño sin comprimir en logs, métricas y trazas",
"used": "Tamaño de los datos"
},
"limit": {
"used": "Ingerido hoy (UTC)",
"ofLimit": "de {limit}",
"daysOver": "Días por encima del límite este mes",
"approachingLimit": "Cerca del límite",
"overLimit": "Límite superado",
"ofUnlimited": "de ilimitado",
"unavailable": "Límite de ingesta no disponible",
"blockNote": "La búsqueda se bloquea tras más de {allowed} día por encima del límite | La búsqueda se bloquea tras más de {allowed} días por encima del límite",
"daysOverOf": "{days} de {allowed} permitidos",
"expiredLine": "Licencia caducada",
"resets": "Se restablece a las 00:00 UTC, en {countdown}",
"subtitle": "El límite diario cuenta el día UTC, no una ventana móvil de 24 h"
},
"empty": {
"incidents": "No hay incidentes abiertos",
"anomalies": "No se han detectado anomalías en el rango de tiempo seleccionado",
"anomalyNoChecks": "No hay comprobaciones de anomalías configuradas",
"services": "No hay datos de servicio en el rango de tiempo seleccionado",
"recentEvents": "No hay eventos de alerta en el rango de tiempo seleccionado"
},
"stale": {
"message": "Mostrando datos de {time}. Error al actualizar.",
"retry": "Reintentar"
},
"dataAsOf": "Datos a fecha de {time}",
"status": {
"attention": "Requiere atención",
"checking": "Comprobando…",
"partial": "Algunas comprobaciones no se pudieron cargar",
"clear": "Nada requiere atención",
"items": {
"firing": "{count} alerta disparada | {count} alertas disparadas",
"failing": "{count} regla con errores | {count} reglas con errores",
"incidents": "{count} incidente abierto | {count} incidentes abiertos",
"pipelines": "{count} pipeline con errores | {count} pipelines con errores",
"quiet": "{count} stream inactivo | {count} streams inactivos"
},
"unmonitored": "Todavía no se está monitorizando nada"
},
"timeRange": {
"heading": "En el rango de tiempo seleccionado"
}
},
"chatHistory": {
"title": "Charlas",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "¿Reiniciar {count} colectores?",
"restartMessage": "Cada colector se reinicia con su próximo mensaje del servidor. La recopilación de datos se detiene brevemente en cada host.",
"restartQueued": "Reinicio solicitado para {count} colector | Reinicio solicitado para {count} colectores",
"restartOffline": "Este colector está desconectado. Reinícialo cuando vuelva a estar en línea.",
"restartUnsupported": "Este colector no admite comandos de reinicio",
"systemSections": "Secciones gestionadas por el sistema",
"systemSectionsCaption": "Extensión de OpAMP y telemetría del servicio, definidas por el supervisor y el servidor",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Abre el editor de {config}. Publica una versión corregida y cambia esta flota a ella; este despliegue queda entonces reemplazado.",
"confirmResume": "El mismo despliegue continúa desde la fase {n}.",
"confirmRollback": "Inicia un despliegue inmediato de vuelta a la versión estable, {config} v{version}. Los colectores que ya la tienen no reciben nada.",
"confirmRollbackNoStable": "Esta flota no tiene una versión estable, por lo que el despliegue finaliza y sus colectores dejan de recopilar hasta que vuelvas a desplegar.",
"confirmRollbackNoStable": "Inicia un despliegue inmediato de vuelta a la versión estable.",
"confirmSkip": "Excluye {count} colector con fallos y continúa. Se marca como excluido y se vuelve a incluir en el siguiente despliegue. | Excluye {count} colectores con fallos y continúa. Se marcan como excluidos y se vuelven a incluir en el siguiente despliegue.",
"confirmed": "Confirmado",
"eventConverged": "La fase {n} convergió",
@@ -29358,7 +29479,6 @@
"phaseN": "Fase {n}",
"phaseTimedOut": "Tiempo agotado",
"phaseTimeoutNote": "Una fase que no confirma ni rechaza en {minutes} min detiene el despliegue (ajuste de la flota)",
"phaseTimeoutNoteSeconds": "Una fase que no confirme ni rechace en {minutes} min {seconds} s detiene el despliegue (ajuste de la flota)",
"phaseVerifying": "Verificando",
"phases": "Fases",
"planEmpty": "No hay ningún colector en línea, así que el despliegue esperará hasta que se conecte uno",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Heredar ({value})",
"offlineHeartbeats": "Sin conexión tras latidos perdidos",
"offlineHeartbeatsHelp": "Un colector está sin conexión tras este número de intervalos de latido sin mensaje",
"offlineRatio": "Proporción de colectores desconectados (%)",
"offlineRatioHelp": "Detiene la fase cuando más de esta proporción de sus colectores está desconectada",
"offlineRatio": "Evento de proporción sin conexión (%)",
"offlineRatioHelp": "Registra un evento de flota cuando más de esta proporción de colectores está sin conexión",
"outOfRange": "Introduce un número de {min} a {max}",
"phaseTimeout": "Tiempo de espera de la fase (segundos)",
"phaseTimeoutHelp": "Una fase del despliegue que no confirma ni rechaza en este tiempo se detiene",
+169 -49
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Non inclus dans votre licence",
"title": "{feature} ne fait pas partie de votre licence.",
"description": "Votre administrateur peut l'ajouter en mettant à jour la licence, ou contactez le service commercial pour obtenir une licence qui l'inclut.",
"thisFeature": "Cette fonctionnalité",
"contactSales": "Contacter le service commercial",
"toastTitle": "Non inclus dans votre licence",
"toastMessage": "Votre licence n'inclut pas : {features}. Demandez à votre administrateur de mettre à jour la licence.",
"toastMessageGeneric": "Votre licence n'inclut pas cette fonctionnalité. Demandez à votre administrateur de mettre à jour la licence.",
"bannerMessage": "Cette fonctionnalité est verrouillée. Elle nécessite la fonctionnalité {feature} dans votre licence.",
"bannerMessageGeneric": "Cette page nécessite une fonctionnalité qui ne fait pas partie de votre licence.",
"headerLocked": "Verrouillé : nécessite {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Aucun historique pour le moment",
@@ -3420,22 +3407,6 @@
"contact_name": "Nom du contact",
"contact_email": "E-mail de contact",
"environment_type": "Type d'environnement",
"features": "Fonctionnalités",
"features_all_unlocked": "Votre licence débloque toutes les fonctionnalités.",
"features_summary": "Votre licence débloque {ratio}. {free} fonctionnalité gratuite est toujours incluse. | Votre licence débloque {ratio}. {free} fonctionnalités gratuites sont toujours incluses.",
"features_paid_ratio": "{unlocked} fonctionnalité payante sur {paid} | {unlocked} fonctionnalités payantes sur {paid}",
"features_unlocked": "Débloquée",
"features_missing": "Non incluse dans votre licence",
"features_request_access": "Demander l'accès",
"features_search": "Rechercher {count} fonctionnalité | Rechercher les {count} fonctionnalités",
"features_filter_all": "Toutes",
"features_filter_free": "Gratuites",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Aucune fonctionnalité ne correspond à votre recherche",
"features_none": "Aucune fonctionnalité dans ce groupe",
"features_free_heading": "Gratuites, incluses avec chaque licence",
"features_new": "Nouvelles depuis votre licence",
"features_filter_new": "Nouvelles",
"request_new_license": "Demander une nouvelle licence",
"add_new_license_key": "Ajouter une nouvelle clé de licence",
"refresh_license_limits": "Actualiser les limites de licence",
@@ -9918,7 +9889,7 @@
"activeAnomalies": "Anomalies actives",
"activeIncidents": "Incidents actifs",
"services": "Des services",
"recentEvents": "Évènements récents",
"recentEvents": "Événements récents",
"investigate": "Enquêter →",
"viewAll": "Afficher tout",
"showingOf": "Affichage de {shown} de {total}",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Faire défiler les services vers la gauche",
"scrollRight": "Faire défiler les services vers la droite",
"untitledIncident": "Incident sans titre",
"allClear": "Tout est clair",
"allClearDesc": "Aucune anomalie active, aucun incident ou service dégradé dans la fenêtre de temps sélectionnée.",
"allClearDescScoped": "Aucun incident ouvert, aucune anomalie ni service dégradé dans la plage de temps sélectionnée.",
"allClearPartial": "Rien à signaler",
"allClearPartialDesc": "Rien ne nécessite votre attention. Nous n'avons pas vérifié {areas}, car votre licence n'inclut pas ces fonctionnalités.",
"areaAlerts": "Alertes",
"areaAnomalies": "Détection d'anomalies",
"areaIncidents": "Incidents",
"areaServices": "Services",
"notMonitoredTitle": "Rien n'est encore surveillé",
"notMonitoredDesc": "Vos données arrivent, mais aucune alerte ni vérification d'anomalie ne s'exécute dessus. Ajoutez-en une et la Vue d'ensemble listera tout ce qui nécessite votre attention.",
"notMonitoredAlert": "Créer une alerte",
"notMonitoredAlertDesc": "Soyez notifié lorsqu'une requête franchit un seuil",
"notMonitoredAnomaly": "Configurer la détection d'anomalies",
"notMonitoredAnomalyDesc": "Signalez un volume inhabituel sans définir de seuil",
"emptyActionAlerts": "Afficher les règles d'alerte",
"emptyActionAlertsDesc": "Passez en revue les alertes et les seuils configurés",
"emptyActionLogs": "Explorez les journaux",
"emptyActionLogsDesc": "Recherchez et filtrez vos flux de logs",
"emptyActionTraces": "Explorer les traces",
"emptyActionTracesDesc": "Inspectez les traces distribuées et la latence des services",
"anomalyDetectedTitle": "Anomalie détectée — {name}",
"anomalyDetectedDescription": "Flux : {stream} · {count} point(s) anormal(s)",
"secondsAgo": "il y a {count}s",
"minutesAgo": "il y a {count}m",
"hoursAgo": "il y a {count}h",
"daysAgo": "il y a {count}d"
"daysAgo": "il y a {count}d",
"cards": {
"firingAlerts": "Alertes déclenchées",
"failingRules": "Règles en échec",
"openIncidents": "Incidents ouverts",
"pipelineErrors": "Erreurs de pipeline",
"pipelinesOf": "sur {enabled} activés",
"quietStreams": "Flux silencieux",
"firingNone": "Aucune déclenchée",
"noAlertRules": "Aucune règle d'alerte",
"noEnabledRules": "Aucune règle activée",
"failingNone": "Aucune en échec",
"failingNote": "Dernière évaluation en erreur",
"incidentsNone": "Aucun ouvert",
"noPipelines": "Aucun pipeline",
"noStreams": "Aucun flux",
"quietNote": "A cessé d'écrire ces 7 derniers jours",
"criticalUnit": "{count} au niveau critique",
"deliveryFailures": "Échecs de livraison des alertes",
"deliveryNote": "Alertes déclenchées mais non notifiées",
"oldestFiring": "Plus ancienne déclenchée depuis {age}"
},
"state": {
"tileForbidden": "Aucun accès",
"tileLocked": "Verrouillé",
"tileError": "Chargement impossible",
"tileUnavailable": "Non disponible",
"sectionLocked": "Disponible avec une licence entreprise",
"sectionForbidden": "Votre rôle ne permet pas de voir ces données. Demandez l'accès à un administrateur de l'organisation."
},
"time": {
"minutes": "{minutes} min",
"hoursMinutes": "{hours} h {minutes} min",
"daysHours": "{days} j {hours} h"
},
"level": {
"critical": "Critique",
"warning": "Avertissement",
"okFiring": "Déclenchée"
},
"sections": {
"alerts": "Alertes",
"dataHealth": "Santé des données",
"alertsAndIncidents": "Alertes et incidents",
"limitAndStorage": "Limite d'ingestion et stockage",
"storage": "Taille des données",
"topStreams": "Principaux flux par taille de données"
},
"alerts": {
"firingFor": "Déclenchée depuis",
"showMore": "Afficher {count} autre alerte déclenchée | Afficher {count} autres alertes déclenchées",
"emptyTitle": "Aucune règle d'alerte pour l'instant",
"emptyDesc": "Les règles que vous créez s'affichent ici, avec les déclenchements par niveau et les échecs d'exécution.",
"emptyAction": "Créer une alerte",
"criticalLevel": "Niveau critique",
"warningLevel": "Niveau avertissement",
"rulesFailing": "Règles en échec",
"firingNow": "Déclenchées maintenant, de la plus ancienne à la plus récente",
"noneFiring": "Aucune alerte déclenchée pour le moment",
"rulesActive": "{count} règle active | {count} règles actives",
"rulesFailingToEvaluate": "{count} en échec d'évaluation | {count} en échec d'évaluation",
"subtitle": "{active} · {failing}",
"viewAll": "Voir toutes les alertes",
"rulesEvaluated": "{count} règle est en cours d'évaluation | {count} règles sont en cours d'évaluation"
},
"dataHealth": {
"viewPipelines": "Voir les pipelines",
"noPipelines": "Aucun pipeline pour le moment.",
"pipelinesErrored": "Pipelines en erreur",
"pipelinesNote": "Pipelines activés en erreur",
"quiet": "Flux sans activité",
"quietNote": "Écriture arrêtée il y a entre une heure et 7 jours",
"failing": "Règles d'alerte en échec d'évaluation",
"failingNote": "Angles morts, sans déclenchement",
"delivery": "Échecs de livraison des alertes",
"deliveryNote": "Alertes déclenchées mais non notifiées",
"schema": "Changements de schéma sur 24 h",
"schemaNote": "Nouvelles versions de schéma dans vos flux",
"status": {
"needsAction": "Action requise",
"warning": "Avertissement",
"healthy": "Bon état",
"notSetUp": "Non configuré",
"info": "Info"
},
"compaction": "Tâches de compaction en attente",
"compactionNote": "Tous les types de flux que vous pouvez voir",
"pipelinesActive": "Sur {count} pipeline actif | Sur {count} pipelines actifs",
"subtitle": "Les données arrivent-elles, intactes et livrées ?"
},
"streams": {
"emptyTitle": "Aucun flux pour le moment",
"emptyDesc": "Les flux apparaissent ici dès que des données commencent à arriver.",
"startIngesting": "Commencer l'ingestion",
"allStreams": "Tous les flux",
"shareOfTotal": "{percent} % du total",
"shareUnderOne": "Moins de 1 % du total",
"subtitle": "Les plus grands flux auxquels vous avez accès",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Ouvrir",
"openAria": {
"logs": "Ouvrir les logs",
"metrics": "Ouvrir les métriques",
"traces": "Ouvrir les traces"
},
"logs": "Logs",
"metrics": "Métriques",
"traces": "Traces",
"streams": "Flux",
"events": "Événements",
"samples": "Échantillons",
"spans": "Spans",
"stored": "Taille des données"
},
"storage": {
"emptyTitle": "Aucune donnée pour le moment",
"bySignal": "Taille des données par signal",
"splitNote": "Taille non compressée pour les logs, les métriques et les traces",
"used": "Taille des données"
},
"limit": {
"used": "Ingestion aujourd'hui (UTC)",
"ofLimit": "sur {limit}",
"daysOver": "Jours au-dessus de la limite ce mois-ci",
"approachingLimit": "Limite bientôt atteinte",
"overLimit": "Limite dépassée",
"ofUnlimited": "sur un quota illimité",
"unavailable": "Limite d'ingestion indisponible",
"blockNote": "La recherche est bloquée après plus de {allowed} jour de dépassement | La recherche est bloquée après plus de {allowed} jours de dépassement",
"daysOverOf": "{days} sur {allowed} autorisé(s)",
"expiredLine": "Licence expirée",
"resets": "Réinitialisation à 00:00 UTC, dans {countdown}",
"subtitle": "La limite quotidienne compte le jour UTC, pas une fenêtre glissante de 24 h"
},
"empty": {
"incidents": "Aucun incident ouvert",
"anomalies": "Aucune anomalie détectée dans la plage de temps sélectionnée",
"anomalyNoChecks": "Aucune vérification d'anomalie n'est configurée",
"services": "Aucune donnée de service dans la plage de temps sélectionnée",
"recentEvents": "Aucun événement d'alerte dans la plage de temps sélectionnée"
},
"stale": {
"message": "Affichage des données de {time}. Échec de l'actualisation.",
"retry": "Réessayer"
},
"dataAsOf": "Données en date du {time}",
"status": {
"attention": "Nécessite une attention",
"checking": "Vérification…",
"partial": "Certaines vérifications n'ont pas pu être chargées",
"clear": "Rien ne nécessite d'attention",
"items": {
"firing": "{count} alerte déclenchée | {count} alertes déclenchées",
"failing": "{count} règle en échec | {count} règles en échec",
"incidents": "{count} incident ouvert | {count} incidents ouverts",
"pipelines": "{count} pipeline en erreur | {count} pipelines en erreur",
"quiet": "{count} flux silencieux | {count} flux silencieux"
},
"unmonitored": "Rien n'est encore surveillé"
},
"timeRange": {
"heading": "Dans la plage de temps sélectionnée"
}
},
"chatHistory": {
"title": "Discussions",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Redémarrer {count} collecteurs ?",
"restartMessage": "Chaque collecteur redémarre à son prochain message du serveur. La collecte de données est brièvement interrompue sur chaque hôte.",
"restartQueued": "Redémarrage demandé pour {count} collecteur | Redémarrage demandé pour {count} collecteurs",
"restartOffline": "Ce collecteur est hors ligne. Redémarrez-le une fois qu'il sera de nouveau en ligne.",
"restartUnsupported": "Ce collecteur n'accepte pas les commandes de redémarrage",
"systemSections": "Sections gérées par le système",
"systemSectionsCaption": "Extension OpAMP et télémétrie du service, définies par le superviseur et le serveur",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Ouvre l'éditeur de {config}. Publiez une version corrigée et faites-y passer cette flotte ; ce déploiement est alors remplacé.",
"confirmResume": "Le même déploiement reprend à la phase {n}.",
"confirmRollback": "Lance immédiatement un déploiement de retour à la version stable, {config} v{version}. Les collecteurs qui l'utilisent déjà ne reçoivent rien.",
"confirmRollbackNoStable": "Cette flotte n'a pas de version stable, donc le déploiement se termine et ses collecteurs cessent de collecter jusqu'à votre prochain déploiement.",
"confirmRollbackNoStable": "Lance immédiatement un déploiement de retour à la version stable.",
"confirmSkip": "Exclut {count} collecteur en échec et continue. Il est marqué comme exclu et inclus de nouveau au prochain déploiement. | Exclut {count} collecteurs en échec et continue. Ils sont marqués comme exclus et inclus de nouveau au prochain déploiement.",
"confirmed": "Confirmé",
"eventConverged": "Phase {n} convergée",
@@ -29358,7 +29479,6 @@
"phaseN": "Phase {n}",
"phaseTimedOut": "Délai dépassé",
"phaseTimeoutNote": "Une phase qui ne confirme ni ne rejette en {minutes} min interrompt le déploiement (paramètre de la flotte)",
"phaseTimeoutNoteSeconds": "Une phase qui ne confirme ni ne rejette dans un délai de {minutes} min {seconds} s interrompt le déploiement (paramètre de la flotte)",
"phaseVerifying": "Vérification",
"phases": "Phases",
"planEmpty": "Aucun collecteur n'est en ligne, le déploiement attend qu'un collecteur se connecte",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Hérité ({value})",
"offlineHeartbeats": "Hors ligne après des heartbeats manqués",
"offlineHeartbeatsHelp": "Un collecteur est hors ligne après ce nombre d'intervalles de heartbeat sans message",
"offlineRatio": "Taux hors ligne (%)",
"offlineRatioHelp": "Interrompt la phase lorsque la part de ses collecteurs hors ligne dépasse cette valeur",
"offlineRatio": "Événement de ratio hors ligne (%)",
"offlineRatioHelp": "Enregistre un événement de flotte lorsque plus de cette part de collecteurs est hors ligne",
"outOfRange": "Saisissez un nombre de {min} à {max}",
"phaseTimeout": "Délai d'une phase (secondes)",
"phaseTimeoutHelp": "Une phase de déploiement qui ne confirme ni ne rejette dans ce délai est interrompue",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Non incluso nella tua licenza",
"title": "La funzionalità {feature} non è inclusa nella tua licenza",
"description": "Il tuo amministratore può aggiungerla aggiornando la licenza, oppure contatta il team commerciale per ottenere una licenza che la includa.",
"thisFeature": "Questa funzionalità",
"contactSales": "Contatta il team commerciale",
"toastTitle": "Non incluso nella tua licenza",
"toastMessage": "La tua licenza non include: {features}. Chiedi al tuo amministratore di aggiornare la licenza.",
"toastMessageGeneric": "La tua licenza non include questa funzionalità. Chiedi al tuo amministratore di aggiornare la licenza.",
"bannerMessage": "Questa funzionalità è bloccata. Richiede la funzionalità {feature} nella tua licenza.",
"bannerMessageGeneric": "Questa pagina richiede una funzionalità non inclusa nella tua licenza.",
"headerLocked": "Bloccata: richiede {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Nessuna cronologia ancora",
@@ -3420,22 +3407,6 @@
"contact_name": "Nome del contatto",
"contact_email": "Email di contatto",
"environment_type": "Tipo di ambiente",
"features": "Funzionalità",
"features_all_unlocked": "La tua licenza sblocca tutte le funzionalità.",
"features_summary": "La tua licenza sblocca {ratio}. {free} funzionalità gratuita è sempre inclusa. | La tua licenza sblocca {ratio}. {free} funzionalità gratuite sono sempre incluse.",
"features_paid_ratio": "{unlocked} di {paid} funzionalità a pagamento | {unlocked} di {paid} funzionalità a pagamento",
"features_unlocked": "Sbloccate",
"features_missing": "Non incluse nella tua licenza",
"features_request_access": "Richiedi accesso",
"features_search": "Cerca tra {count} funzionalità | Cerca tra tutte le {count} funzionalità",
"features_filter_all": "Tutte",
"features_filter_free": "Gratuite",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Nessuna funzionalità corrisponde alla ricerca",
"features_none": "Nessuna funzionalità in questo gruppo",
"features_free_heading": "Gratuite, incluse in ogni licenza",
"features_new": "Nuove dopo la tua licenza",
"features_filter_new": "Nuove",
"request_new_license": "Richiedi una nuova licenza",
"add_new_license_key": "Aggiungi nuova chiave di licenza",
"refresh_license_limits": "Aggiorna i limiti delle licenze",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Scorri servizi a sinistra",
"scrollRight": "Scorri servizi a destra",
"untitledIncident": "Incidente senza titolo",
"allClear": "Tutto chiaro",
"allClearDesc": "Nessuna anomalia attiva, incidente o servizio degradato nella finestra temporale selezionata.",
"allClearDescScoped": "Nessun incidente aperto, anomalia o servizio degradato nell'intervallo di tempo selezionato.",
"allClearPartial": "Niente da segnalare",
"allClearPartialDesc": "Nulla richiede attenzione. Non abbiamo verificato {areas}, perché la tua licenza non include queste funzionalità.",
"areaAlerts": "Avvisi",
"areaAnomalies": "Rilevamento anomalie",
"areaIncidents": "Incidenti",
"areaServices": "Servizi",
"notMonitoredTitle": "Non c'è ancora nulla sotto monitoraggio",
"notMonitoredDesc": "I tuoi dati stanno arrivando, ma non viene eseguito alcun controllo di avvisi o anomalie su di essi. Aggiungine uno e la Panoramica elencherà tutto ciò che richiede attenzione.",
"notMonitoredAlert": "Crea un avviso",
"notMonitoredAlertDesc": "Ricevi una notifica quando una query supera una soglia",
"notMonitoredAnomaly": "Configura il rilevamento delle anomalie",
"notMonitoredAnomalyDesc": "Segnala volumi insoliti senza scegliere una soglia",
"emptyActionAlerts": "Visualizza le regole di avviso",
"emptyActionAlertsDesc": "Rivedi gli avvisi e le soglie configurati",
"emptyActionLogs": "Esplora i log",
"emptyActionLogsDesc": "Cerca e filtra i tuoi stream di log",
"emptyActionTraces": "Esplora tracce",
"emptyActionTracesDesc": "Ispeziona le tracce distribuite e la latenza dei servizi",
"anomalyDetectedTitle": "Anomalia rilevata — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} punto/i anomalo/i",
"secondsAgo": "{count} s fa",
"minutesAgo": "{count} min fa",
"hoursAgo": "{count} h fa",
"daysAgo": "{count} g fa"
"daysAgo": "{count} g fa",
"cards": {
"firingAlerts": "Avvisi attivi",
"failingRules": "Regole in errore",
"openIncidents": "Incidenti aperti",
"pipelineErrors": "Errori delle pipeline",
"pipelinesOf": "di {enabled} abilitate",
"quietStreams": "Stream inattivi",
"firingNone": "Nessuno attivo",
"noAlertRules": "Nessuna regola di avviso",
"noEnabledRules": "Nessuna regola abilitata",
"failingNone": "Nessuna in errore",
"failingNote": "Errore nell'ultima valutazione",
"incidentsNone": "Nessuno aperto",
"noPipelines": "Nessuna pipeline",
"noStreams": "Nessuno stream",
"quietNote": "Nessuna scrittura negli ultimi 7 giorni",
"criticalUnit": "{count} a livello critico",
"deliveryFailures": "Errori di consegna degli avvisi",
"deliveryNote": "Avvisi attivati ma non notificati",
"oldestFiring": "Il più vecchio attivo da {age}"
},
"state": {
"tileForbidden": "Nessun accesso",
"tileLocked": "Bloccato",
"tileError": "Impossibile caricare",
"tileUnavailable": "Non disponibile",
"sectionLocked": "Disponibile con una licenza enterprise",
"sectionForbidden": "Il tuo ruolo non può vedere questi dati. Chiedi l'accesso a un amministratore dell'organizzazione."
},
"time": {
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m",
"daysHours": "{days}d {hours}h"
},
"level": {
"critical": "Critico",
"warning": "Avvertenza",
"okFiring": "Attivo"
},
"sections": {
"alerts": "Avvisi",
"dataHealth": "Salute dei dati",
"alertsAndIncidents": "Avvisi e incidenti",
"limitAndStorage": "Limite di ingestione e archiviazione",
"storage": "Dimensione dei dati",
"topStreams": "Stream principali per dimensione dei dati"
},
"alerts": {
"firingFor": "Attivo da",
"showMore": "Mostra altri {count} avviso attivo | Mostra altri {count} avvisi attivi",
"emptyTitle": "Ancora nessuna regola di avviso",
"emptyDesc": "Le regole che crei compaiono qui, con ciò che è attivo per livello e ciò che non riesce a essere eseguito.",
"emptyAction": "Crea un avviso",
"criticalLevel": "Livello critico",
"warningLevel": "Livello di avvertenza",
"rulesFailing": "Regole in errore",
"firingNow": "Attivi ora, dal più vecchio",
"noneFiring": "Nessun avviso attivo in questo momento",
"rulesActive": "{count} regola attiva | {count} regole attive",
"rulesFailingToEvaluate": "{count} con valutazione non riuscita | {count} con valutazione non riuscita",
"subtitle": "{active} · {failing}",
"viewAll": "Visualizza tutti gli avvisi",
"rulesEvaluated": "{count} regola in fase di valutazione | {count} regole in fase di valutazione"
},
"dataHealth": {
"viewPipelines": "Visualizza pipeline",
"noPipelines": "Nessuna pipeline per ora.",
"pipelinesErrored": "Pipeline con errori",
"pipelinesNote": "Pipeline abilitate con un errore",
"quiet": "Stream inattivi",
"quietNote": "Ultima scrittura tra 1 ora e 7 giorni fa",
"failing": "Regole di avviso con valutazione non riuscita",
"failingNote": "Punti ciechi, non attivi",
"delivery": "Errori di consegna degli avvisi",
"deliveryNote": "Avvisi attivati ma non notificati",
"schema": "Modifiche allo schema nelle ultime 24h",
"schemaNote": "Nuove versioni dello schema nei tuoi stream",
"status": {
"needsAction": "Richiede intervento",
"warning": "Attenzione",
"healthy": "Integro",
"notSetUp": "Non configurato",
"info": "Info"
},
"compaction": "Job di compattazione in attesa",
"compactionNote": "Su tutti i tipi di stream visibili",
"pipelinesActive": "Su {count} pipeline attiva | Su {count} pipeline attive",
"subtitle": "I dati arrivano integri e vengono consegnati?"
},
"streams": {
"emptyTitle": "Ancora nessuno stream",
"emptyDesc": "Gli stream compaiono qui quando i dati iniziano ad arrivare.",
"startIngesting": "Inizia l'ingestione",
"allStreams": "Tutti gli stream",
"shareOfTotal": "{percent}% del totale",
"shareUnderOne": "Meno dell'1% del totale",
"subtitle": "Gli stream più grandi a cui puoi accedere",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Apri",
"openAria": {
"logs": "Apri log",
"metrics": "Apri metriche",
"traces": "Apri tracce"
},
"logs": "Log",
"metrics": "Metriche",
"traces": "Tracce",
"streams": "Stream",
"events": "Eventi",
"samples": "Campioni",
"spans": "Span",
"stored": "Dimensione dei dati"
},
"storage": {
"emptyTitle": "Ancora nessun dato",
"bySignal": "Dimensione dei dati per segnale",
"splitNote": "Dimensione non compressa di log, metriche e tracce",
"used": "Dimensione dati"
},
"limit": {
"used": "Ingerito oggi (UTC)",
"ofLimit": "di {limit}",
"daysOver": "Giorni oltre il limite questo mese",
"approachingLimit": "Vicino al limite",
"overLimit": "Oltre il limite",
"ofUnlimited": "di illimitato",
"unavailable": "Limite di ingestione non disponibile",
"blockNote": "La ricerca viene bloccata dopo più di {allowed} giorno oltre il limite | La ricerca viene bloccata dopo più di {allowed} giorni oltre il limite",
"daysOverOf": "{days} di {allowed} consentiti",
"expiredLine": "Licenza scaduta",
"resets": "Si azzera alle 00:00 UTC, tra {countdown}",
"subtitle": "Il limite giornaliero considera il giorno UTC, non 24 ore scorrevoli"
},
"empty": {
"incidents": "Nessun incidente aperto",
"anomalies": "Nessuna anomalia rilevata nell'intervallo di tempo selezionato",
"anomalyNoChecks": "Nessun controllo anomalie configurato",
"services": "Nessun dato dei servizi nell'intervallo di tempo selezionato",
"recentEvents": "Nessun evento di avviso nell'intervallo di tempo selezionato"
},
"stale": {
"message": "Dati visualizzati da {time}. Aggiornamento non riuscito.",
"retry": "Riprova"
},
"dataAsOf": "Dati aggiornati a {time}",
"status": {
"attention": "Richiede attenzione",
"checking": "Verifica in corso…",
"partial": "Impossibile caricare alcuni controlli",
"clear": "Niente richiede attenzione",
"items": {
"firing": "{count} avviso attivo | {count} avvisi attivi",
"failing": "{count} regola in errore | {count} regole in errore",
"incidents": "{count} incidente aperto | {count} incidenti aperti",
"pipelines": "{count} pipeline con errori | {count} pipeline con errori",
"quiet": "{count} stream inattivo | {count} stream inattivi"
},
"unmonitored": "Non è ancora monitorato nulla"
},
"timeRange": {
"heading": "Nell'intervallo di tempo selezionato"
}
},
"chatHistory": {
"title": "Chat",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Riavviare {count} collector?",
"restartMessage": "Ogni collector si riavvia al suo prossimo messaggio dal server. La raccolta dei dati si interrompe brevemente su ogni host.",
"restartQueued": "Riavvio richiesto per {count} collector | Riavvio richiesto per {count} collector",
"restartOffline": "Questo collector è offline. Riavvialo quando sarà di nuovo online.",
"restartUnsupported": "Questo collector non accetta comandi di riavvio",
"systemSections": "Sezioni gestite dal sistema",
"systemSectionsCaption": "Estensione OpAMP e telemetria del servizio, impostate dal supervisor e dal server",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Apre l'editor di {config}. Pubblica una versione corretta e porta questo fleet su di essa; questo rollout viene quindi sostituito.",
"confirmResume": "Lo stesso rollout continua dalla fase {n}.",
"confirmRollback": "Avvia subito un rollout di ritorno alla versione stabile, {config} v{version}. I collector che la eseguono già non ricevono nulla.",
"confirmRollbackNoStable": "Questa flotta non ha una versione stabile, quindi il rollout termina e i suoi collector smettono di raccogliere finché non esegui di nuovo il deployment.",
"confirmRollbackNoStable": "Avvia subito un rollout di ritorno alla versione stabile.",
"confirmSkip": "Esclude {count} collector in errore e continua. Viene contrassegnato come escluso e reincluso nel prossimo rollout. | Esclude {count} collector in errore e continua. Vengono contrassegnati come esclusi e reinclusi nel prossimo rollout.",
"confirmed": "Confermato",
"eventConverged": "Fase {n} convergente",
@@ -29358,7 +29479,6 @@
"phaseN": "Fase {n}",
"phaseTimedOut": "Scaduta",
"phaseTimeoutNote": "Una fase che non conferma né rifiuta entro {minutes} min interrompe il rollout (impostazione del fleet)",
"phaseTimeoutNoteSeconds": "Una fase che non conferma né rifiuta entro {minutes} min {seconds} s interrompe il rollout (impostazione della flotta)",
"phaseVerifying": "In verifica",
"phases": "Fasi",
"planEmpty": "Nessun collector è online, quindi il rollout attende che uno si connetta",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Eredita ({value})",
"offlineHeartbeats": "Offline dopo heartbeat mancati",
"offlineHeartbeatsHelp": "Un collector è offline dopo questo numero di intervalli di heartbeat senza alcun messaggio",
"offlineRatio": "Percentuale offline (%)",
"offlineRatioHelp": "Interrompe la fase quando la percentuale dei suoi collector offline supera questo valore",
"offlineRatio": "Evento di quota offline (%)",
"offlineRatioHelp": "Registra un evento del fleet quando più di questa quota di collector è offline",
"outOfRange": "Inserisci un numero da {min} a {max}",
"phaseTimeout": "Timeout della fase (secondi)",
"phaseTimeoutHelp": "Una fase del rollout che non conferma né rifiuta entro questo tempo viene interrotta",
+170 -50
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "ライセンスに含まれていません",
"title": "{feature} はライセンスに含まれていません",
"description": "管理者がライセンスを更新して追加するか、含まれているライセンスについて営業担当者にお問い合わせください。",
"thisFeature": "この機能",
"contactSales": "営業担当者に問い合わせる",
"toastTitle": "ライセンスに含まれていません",
"toastMessage": "ライセンスに含まれていない機能: {features}。管理者にライセンスの更新を依頼してください。",
"toastMessageGeneric": "ライセンスにこの機能は含まれていません。管理者にライセンスの更新を依頼してください。",
"bannerMessage": "この機能はロックされています。ライセンスに {feature} 機能が必要です。",
"bannerMessageGeneric": "このページにはライセンスに含まれていない機能が必要です。",
"headerLocked": "ロック中: {feature} が必要"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "履歴はまだありません",
@@ -3420,22 +3407,6 @@
"contact_name": "連絡先名",
"contact_email": "連絡先電子メール",
"environment_type": "環境タイプ",
"features": "機能",
"features_all_unlocked": "ライセンスですべての機能が利用可能です。",
"features_summary": "ライセンスで {ratio} が利用可能です。{free} 個の無料機能が常に含まれます。 | ライセンスで {ratio} が利用可能です。{free} 個の無料機能が常に含まれます。",
"features_paid_ratio": "{paid} 個の有料機能のうち {unlocked} 個 | {paid} 個の有料機能のうち {unlocked} 個",
"features_unlocked": "利用可能",
"features_missing": "ライセンスに含まれていません",
"features_request_access": "アクセスをリクエスト",
"features_search": "{count} 件の機能を検索 | すべての {count} 件の機能を検索",
"features_filter_all": "すべて",
"features_filter_free": "無料",
"features_group_heading": "{heading} · {count}",
"features_no_match": "検索に一致する機能はありません",
"features_none": "このグループに機能はありません",
"features_free_heading": "無料、すべてのライセンスに含まれます",
"features_new": "ライセンス発行後に追加された機能",
"features_filter_new": "新規",
"request_new_license": "新しいライセンスをリクエスト",
"add_new_license_key": "新しいライセンスキーを追加",
"refresh_license_limits": "ライセンス制限を更新",
@@ -9915,8 +9886,8 @@
"alertsSuffix": "アラート",
"viewingLabel": "— 表示中",
"timesSymbol": "×",
"activeAnomalies": "アクティブ・アノマリー",
"activeIncidents": "アクティブインシデント",
"activeAnomalies": "アクティブな異常",
"activeIncidents": "アクティブなインシデント",
"services": "サービス",
"recentEvents": "最近のイベント",
"investigate": "調査する →",
@@ -9928,33 +9899,184 @@
"scrollLeft": "サービスを左にスクロール",
"scrollRight": "サービスを右にスクロール",
"untitledIncident": "無題のインシデント",
"allClear": "すべてクリア",
"allClearDesc": "選択した時間枠には、アクティブな異常、インシデント、または低下したサービスはありません。",
"allClearDescScoped": "選択した期間に未解決のインシデント、異常、サービス劣化はありません。",
"allClearPartial": "報告事項はありません",
"allClearPartialDesc": "注意が必要な項目はありません。ライセンスに対象の機能が含まれていないため、{areas}は確認していません。",
"areaAlerts": "アラート",
"areaAnomalies": "異常検知",
"areaIncidents": "インシデント",
"areaServices": "サービス",
"notMonitoredTitle": "まだ何も監視されていません",
"notMonitoredDesc": "データは到着していますが、アラートや異常検知が実行されていません。追加すると、概要に注意が必要な項目が表示されます。",
"notMonitoredAlert": "アラートを作成",
"notMonitoredAlertDesc": "クエリがしきい値を超えたときに通知を受け取ります",
"notMonitoredAnomaly": "異常検知を設定",
"notMonitoredAnomalyDesc": "しきい値を指定せずに異常なボリュームを検出します",
"emptyActionAlerts": "アラートルールを表示する",
"emptyActionAlertsDesc": "設定したアラートと閾値を確認",
"emptyActionLogs": "ログを見る",
"emptyActionLogsDesc": "ログストリームの検索とフィルタリング",
"emptyActionTraces": "トレースを探索",
"emptyActionTracesDesc": "分散トレースとサービスレイテンシーを確認",
"anomalyDetectedTitle": "異常を検出 — {name}",
"anomalyDetectedDescription": "ストリーム: {stream} · {count} 異常ポイント",
"secondsAgo": "{count}秒前",
"minutesAgo": "{count}分前",
"hoursAgo": "{count}時間前",
"daysAgo": "{count}日前"
"daysAgo": "{count}日前",
"cards": {
"firingAlerts": "発火中のアラート",
"failingRules": "失敗しているルール",
"openIncidents": "未解決のインシデント",
"pipelineErrors": "パイプラインエラー",
"pipelinesOf": "うち {enabled} 件有効",
"quietStreams": "停止中のストリーム",
"firingNone": "発火なし",
"noAlertRules": "アラートルールなし",
"noEnabledRules": "有効なルールなし",
"failingNone": "失敗なし",
"failingNote": "前回の評価でエラー",
"incidentsNone": "未解決なし",
"noPipelines": "パイプラインなし",
"noStreams": "ストリームなし",
"quietNote": "過去 7 日間に書き込みが停止",
"criticalUnit": "クリティカルレベルが {count} 件",
"deliveryFailures": "アラート配信の失敗",
"deliveryNote": "発火したが通知できなかったアラート",
"oldestFiring": "最も古い発火から {age}"
},
"state": {
"tileForbidden": "アクセス権限なし",
"tileLocked": "ロック済み",
"tileError": "読み込めませんでした",
"tileUnavailable": "利用できません",
"sectionLocked": "エンタープライズライセンスで利用可能",
"sectionForbidden": "お使いのロールではこのデータを表示できません。組織の管理者にアクセス権を依頼してください。"
},
"time": {
"minutes": "{minutes}分",
"hoursMinutes": "{hours}時間 {minutes}分",
"daysHours": "{days}日 {hours}時間"
},
"level": {
"critical": "クリティカル",
"warning": "警告",
"okFiring": "発火中"
},
"sections": {
"alerts": "アラート",
"dataHealth": "データヘルス",
"alertsAndIncidents": "アラートとインシデント",
"limitAndStorage": "取り込み上限とストレージ",
"storage": "データサイズ",
"topStreams": "データサイズ別トップストリーム"
},
"alerts": {
"firingFor": "発火継続時間",
"showMore": "発火中のアラートをさらに {count} 件表示 | 発火中のアラートをさらに {count} 件表示",
"emptyTitle": "アラートルールはまだありません",
"emptyDesc": "作成したルールはここに表示され、レベル別の発火状況と実行に失敗しているルールを確認できます。",
"emptyAction": "アラートを作成",
"criticalLevel": "クリティカルレベル",
"warningLevel": "警告レベル",
"rulesFailing": "失敗しているルール",
"firingNow": "現在発火中(古い順)",
"noneFiring": "現在発火中のアラートはありません",
"rulesActive": "{count} 件のルールがアクティブ | {count} 件のルールがアクティブ",
"rulesFailingToEvaluate": "{count} 件が評価に失敗 | {count} 件が評価に失敗",
"subtitle": "{active} · {failing}",
"viewAll": "すべてのアラートを表示",
"rulesEvaluated": "{count} 件のルールを評価中 | {count} 件のルールを評価中"
},
"dataHealth": {
"viewPipelines": "パイプラインを表示",
"noPipelines": "パイプラインはまだありません。",
"pipelinesErrored": "エラーのあるパイプライン",
"pipelinesNote": "エラーが発生している有効なパイプライン",
"quiet": "書き込みが止まったストリーム",
"quietNote": "1 時間~7 日前に書き込みが停止",
"failing": "評価に失敗しているアラートルール",
"failingNote": "発火していない盲点",
"delivery": "アラート配信の失敗",
"deliveryNote": "発火したが通知できなかったアラート",
"schema": "24 時間のスキーマ変更",
"schemaNote": "各ストリームの新しいスキーマバージョン",
"status": {
"needsAction": "対応が必要",
"warning": "警告",
"healthy": "正常",
"notSetUp": "未設定",
"info": "情報"
},
"compaction": "保留中のコンパクションジョブ",
"compactionNote": "表示可能なストリームタイプ全体",
"pipelinesActive": "アクティブなパイプライン {count} 件中 | アクティブなパイプライン {count} 件中",
"subtitle": "データは欠損なく届き、配信されていますか?"
},
"streams": {
"emptyTitle": "ストリームはまだありません",
"emptyDesc": "データの到着が始まると、ストリームがここに表示されます。",
"startIngesting": "取り込みを開始",
"allStreams": "すべてのストリーム",
"shareOfTotal": "全体の {percent}%",
"shareUnderOne": "全体の 1% 未満",
"subtitle": "アクセス可能な最大のストリーム",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "開く",
"openAria": {
"logs": "ログを開く",
"metrics": "メトリクスを開く",
"traces": "トレースを開く"
},
"logs": "ログ",
"metrics": "メトリクス",
"traces": "トレース",
"streams": "ストリーム",
"events": "イベント",
"samples": "サンプル",
"spans": "スパン",
"stored": "データサイズ"
},
"storage": {
"emptyTitle": "データはまだありません",
"bySignal": "シグナル別のデータサイズ",
"splitNote": "ログ、メトリクス、トレース全体の非圧縮サイズ",
"used": "データサイズ"
},
"limit": {
"used": "本日の取り込み量 (UTC)",
"ofLimit": "{limit} のうち",
"daysOver": "今月の上限超過日数",
"approachingLimit": "上限に接近",
"overLimit": "上限超過",
"ofUnlimited": "無制限",
"unavailable": "取り込み上限を取得できません",
"blockNote": "{allowed} 日を超えて上限を超過すると検索がブロックされます | {allowed} 日を超えて上限を超過すると検索がブロックされます",
"daysOverOf": "{allowed} 日中 {days} 日",
"expiredLine": "ライセンスの有効期限が切れています",
"resets": "00:00 UTC にリセット({countdown} 後)",
"subtitle": "日次上限は UTC の 1 日単位でカウントされ、ローリング 24 時間ではありません"
},
"empty": {
"incidents": "オープンなインシデントはありません",
"anomalies": "選択した時間範囲に異常は検出されませんでした",
"anomalyNoChecks": "異常チェックが設定されていません",
"services": "選択した時間範囲にサービスデータはありません",
"recentEvents": "選択した時間範囲にアラートイベントはありません"
},
"stale": {
"message": "{time} 時点のデータを表示しています。更新に失敗しました。",
"retry": "再試行"
},
"dataAsOf": "{time} 時点のデータ",
"status": {
"attention": "対応が必要",
"checking": "確認中…",
"partial": "一部のチェックを読み込めませんでした",
"clear": "対応が必要な項目はありません",
"items": {
"firing": "{count} 件のアラートが発火中 | {count} 件のアラートが発火中",
"failing": "{count} 件のルールが失敗しています | {count} 件のルールが失敗しています",
"incidents": "{count} 件のオープンなインシデント | {count} 件のオープンなインシデント",
"pipelines": "{count} 件のエラーのあるパイプライン | {count} 件のエラーのあるパイプライン",
"quiet": "{count} 件のデータのないストリーム | {count} 件のデータのないストリーム"
},
"unmonitored": "まだ何も監視されていません"
},
"timeRange": {
"heading": "選択した時間範囲内"
}
},
"chatHistory": {
"title": "チャット",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "{count} 台のコレクターを再起動しますか?",
"restartMessage": "各コレクターは、サーバーからの次のメッセージで再起動します。各ホストでデータ収集が一時的に停止します。",
"restartQueued": "{count} 台のコレクターの再起動をリクエストしました | {count} 台のコレクターの再起動をリクエストしました",
"restartOffline": "このコレクターはオフラインです。復旧したら再起動してください。",
"restartUnsupported": "このコレクターは再起動コマンドに対応していません",
"systemSections": "システム管理セクション",
"systemSectionsCaption": "OpAMP 拡張とサービステレメトリ。スーパーバイザーとサーバーが設定します",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "{config} のエディターを開きます。修正版を公開し、このフリートをそのバージョンに変更してください。このロールアウトは置き換えられます。",
"confirmResume": "同じロールアウトがフェーズ {n} から続行されます。",
"confirmRollback": "安定バージョン {config} v{version} への即時ロールアウトを開始します。すでにそのバージョンのコレクターには何も送信されません。",
"confirmRollbackNoStable": "このフリートには安定バージョンがないため、ロールアウトは終了し、再度デプロイするまでコレクターは収集を停止します。",
"confirmRollbackNoStable": "安定バージョンへの即時ロールアウトを開始します。",
"confirmSkip": "失敗している {count} 台のコレクターを除外して続行します。除外済みとしてマークされ、次のロールアウトで再び対象になります。 | 失敗している {count} 台のコレクターを除外して続行します。除外済みとしてマークされ、次のロールアウトで再び対象になります。",
"confirmed": "確認済み",
"eventConverged": "フェーズ {n} が収束",
@@ -29358,7 +29479,6 @@
"phaseN": "フェーズ {n}",
"phaseTimedOut": "タイムアウト",
"phaseTimeoutNote": "{minutes} 分以内に承認も拒否もされないフェーズがあると、ロールアウトは停止します(フリート設定)",
"phaseTimeoutNoteSeconds": "{minutes}分{seconds}秒以内に確認も拒否もしないフェーズはロールアウトを停止します(フリート設定)",
"phaseVerifying": "検証中",
"phases": "フェーズ",
"planEmpty": "オンラインのコレクターがないため、コレクターが接続するまでロールアウトは待機します。",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "継承({value})",
"offlineHeartbeats": "オフラインと判定するハートビート欠落回数",
"offlineHeartbeatsHelp": "メッセージがないままこの回数のハートビート間隔が経過すると、コレクターはオフラインとみなされます",
"offlineRatio": "オフライン率(%)",
"offlineRatioHelp": "コレクターのオフライン率がこの割合を超えるとフェーズを停止します",
"offlineRatio": "オフライン率イベント(%)",
"offlineRatioHelp": "オフラインのコレクターの割合がこの値を超えると、フリートイベントを記録します",
"outOfRange": "{min} から {max} までの数値を入力してください",
"phaseTimeout": "フェーズのタイムアウト(秒)",
"phaseTimeoutHelp": "この時間内に承認も拒否もされないロールアウトフェーズがあると、停止します",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "라이선스에 포함되지 않음",
"title": "{feature} 기능은 라이선스에 포함되어 있지 않습니다",
"description": "관리자가 라이선스를 업데이트하여 추가할 수 있으며, 해당 기능이 포함된 라이선스가 필요하면 영업팀에 문의하세요.",
"thisFeature": "이 기능",
"contactSales": "영업팀 문의",
"toastTitle": "라이선스에 포함되지 않음",
"toastMessage": "라이선스에 다음이 포함되어 있지 않습니다: {features}. 관리자에게 라이선스 업데이트를 요청하세요.",
"toastMessageGeneric": "라이선스에 이 기능이 포함되어 있지 않습니다. 관리자에게 라이선스 업데이트를 요청하세요.",
"bannerMessage": "이 기능은 잠겨 있습니다. 라이선스에 {feature} 기능이 필요합니다.",
"bannerMessageGeneric": "이 페이지에는 라이선스에 없는 기능이 필요합니다.",
"headerLocked": "잠김: {feature} 필요"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "아직 기록이 없습니다",
@@ -3420,22 +3407,6 @@
"contact_name": "담당자 이름",
"contact_email": "연락처 이메일",
"environment_type": "환경 유형",
"features": "기능",
"features_all_unlocked": "라이선스로 모든 기능을 사용할 수 있습니다.",
"features_summary": "라이선스로 {ratio}을(를) 사용할 수 있습니다. 무료 기능 {free}개는 항상 포함됩니다. | 라이선스로 {ratio}을(를) 사용할 수 있습니다. 무료 기능 {free}개는 항상 포함됩니다.",
"features_paid_ratio": "{paid}개 유료 기능 중 {unlocked}개 | {paid}개 유료 기능 중 {unlocked}개",
"features_unlocked": "사용 가능",
"features_missing": "라이선스에 포함되지 않음",
"features_request_access": "액세스 요청",
"features_search": "{count}개 기능 검색 | 전체 {count}개 기능 검색",
"features_filter_all": "전체",
"features_filter_free": "무료",
"features_group_heading": "{heading} · {count}",
"features_no_match": "검색과 일치하는 기능이 없습니다",
"features_none": "이 그룹에 기능이 없습니다",
"features_free_heading": "무료, 모든 라이선스에 포함",
"features_new": "라이선스 이후 새로 추가된 기능",
"features_filter_new": "신규",
"request_new_license": "새 라이선스 요청",
"add_new_license_key": "새 라이선스 키 추가",
"refresh_license_limits": "라이선스 제한 새로고침",
@@ -9928,33 +9899,184 @@
"scrollLeft": "서비스 왼쪽으로 스크롤",
"scrollRight": "서비스 오른쪽으로 스크롤",
"untitledIncident": "제목 없는 인시던트",
"allClear": "모든 시스템 정상",
"allClearDesc": "선택한 기간에 활성 이상 징후, 인시던트 또는 성능이 저하된 서비스가 없습니다. 인프라가 정상입니다.",
"allClearDescScoped": "선택한 시간 범위에 미해결 인시던트, 이상 징후 또는 성능이 저하된 서비스가 없습니다.",
"allClearPartial": "보고할 내용이 없습니다",
"allClearPartialDesc": "주의가 필요한 항목이 없습니다. 라이선스에 해당 기능이 포함되어 있지 않아 {areas} 항목은 확인하지 않았습니다.",
"areaAlerts": "알림",
"areaAnomalies": "이상 탐지",
"areaIncidents": "인시던트",
"areaServices": "서비스",
"notMonitoredTitle": "아직 모니터링하는 항목이 없습니다",
"notMonitoredDesc": "데이터는 도착하고 있지만 이에 대해 실행되는 알림이나 이상 감지 검사가 없습니다. 하나를 추가하면 개요에서 주의가 필요한 항목을 모두 나열합니다.",
"notMonitoredAlert": "알림 생성",
"notMonitoredAlertDesc": "쿼리가 임계값을 초과하면 알림 받기",
"notMonitoredAnomaly": "이상 감지 설정",
"notMonitoredAnomalyDesc": "임계값을 지정하지 않고 비정상적인 볼륨 표시",
"emptyActionAlerts": "알림 규칙 보기",
"emptyActionAlertsDesc": "구성된 알림 및 임계값 검토",
"emptyActionLogs": "로그 살펴보기",
"emptyActionLogsDesc": "로그 스트림 검색 및 필터링",
"emptyActionTraces": "트레이스 탐색",
"emptyActionTracesDesc": "분산 트레이스 및 서비스 지연 시간을 검사하세요.",
"anomalyDetectedTitle": "이상 감지 — {name}",
"anomalyDetectedDescription": "스트림: {stream} · 이상 지점 {count}개",
"secondsAgo": "{count}초 전",
"minutesAgo": "{count}분 전",
"hoursAgo": "{count}시간 전",
"daysAgo": "{count}일 전"
"daysAgo": "{count}일 전",
"cards": {
"firingAlerts": "발생 중인 알림",
"failingRules": "실패 중인 규칙",
"openIncidents": "미해결 인시던트",
"pipelineErrors": "파이프라인 오류",
"pipelinesOf": "{enabled}개 활성화됨",
"quietStreams": "조용한 스트림",
"firingNone": "발생 중인 알림 없음",
"noAlertRules": "알림 규칙 없음",
"noEnabledRules": "활성화된 규칙 없음",
"failingNone": "실패 중인 규칙 없음",
"failingNote": "마지막 평가 오류",
"incidentsNone": "미해결 인시던트 없음",
"noPipelines": "파이프라인 없음",
"noStreams": "스트림 없음",
"quietNote": "지난 7일 동안 쓰기 중단",
"criticalUnit": "심각 수준 {count}개",
"deliveryFailures": "알림 전송 실패",
"deliveryNote": "발생했지만 알림을 보내지 못한 알림",
"oldestFiring": "{age} 동안 발생 중인 가장 오래된 알림"
},
"state": {
"tileForbidden": "접근 권한 없음",
"tileLocked": "잠김",
"tileError": "불러올 수 없음",
"tileUnavailable": "사용할 수 없음",
"sectionLocked": "엔터프라이즈 라이선스에서 사용 가능",
"sectionForbidden": "현재 역할로는 이 데이터를 볼 수 없습니다. 조직 관리자에게 접근 권한을 요청하세요."
},
"time": {
"minutes": "{minutes}분",
"hoursMinutes": "{hours}시간 {minutes}분",
"daysHours": "{days}일 {hours}시간"
},
"level": {
"critical": "심각",
"warning": "경고",
"okFiring": "발생"
},
"sections": {
"alerts": "알림",
"dataHealth": "데이터 상태",
"alertsAndIncidents": "알림 및 인시던트",
"limitAndStorage": "수집 한도 및 스토리지",
"storage": "데이터 크기",
"topStreams": "데이터 크기 기준 상위 스트림"
},
"alerts": {
"firingFor": "발생 기간",
"showMore": "발생 중인 알림 {count}개 더 보기 | 발생 중인 알림 {count}개 더 보기",
"emptyTitle": "아직 알림 규칙이 없습니다",
"emptyDesc": "생성한 규칙이 여기에 표시되며, 수준별 발생 현황과 실행 실패 항목을 보여줍니다.",
"emptyAction": "알림 생성",
"criticalLevel": "심각 수준",
"warningLevel": "경고 수준",
"rulesFailing": "실패 중인 규칙",
"firingNow": "현재 발생 중, 오래된 순",
"noneFiring": "현재 발생 중인 알림이 없습니다",
"rulesActive": "{count}개 규칙 활성 | {count}개 규칙 활성",
"rulesFailingToEvaluate": "{count}개 평가 실패 | {count}개 평가 실패",
"subtitle": "{active} · {failing}",
"viewAll": "모든 알림 보기",
"rulesEvaluated": "{count}개 규칙 평가 중 | {count}개 규칙 평가 중"
},
"dataHealth": {
"viewPipelines": "파이프라인 보기",
"noPipelines": "아직 파이프라인이 없습니다.",
"pipelinesErrored": "오류가 있는 파이프라인",
"pipelinesNote": "오류가 있는 활성화된 파이프라인",
"quiet": "수집이 끊긴 스트림",
"quietNote": "1시간~7일 전에 쓰기가 중단됨",
"failing": "평가에 실패한 알림 규칙",
"failingNote": "사각지대이며 발생하지 않음",
"delivery": "알림 전달 실패",
"deliveryNote": "발생했지만 알림을 보낼 수 없었던 알림",
"schema": "24시간 내 스키마 변경",
"schemaNote": "스트림 전체의 새 스키마 버전",
"status": {
"needsAction": "조치 필요",
"warning": "경고",
"healthy": "정상",
"notSetUp": "설정되지 않음",
"info": "정보"
},
"compaction": "대기 중인 컴팩션 작업",
"compactionNote": "볼 수 있는 스트림 유형 전체",
"pipelinesActive": "{count}개 활성 파이프라인 중 | {count}개 활성 파이프라인 중",
"subtitle": "데이터가 온전하게 도착하고 전달되고 있나요?"
},
"streams": {
"emptyTitle": "아직 스트림이 없습니다",
"emptyDesc": "데이터가 도착하기 시작하면 여기에 스트림이 표시됩니다.",
"startIngesting": "수집 시작",
"allStreams": "모든 스트림",
"shareOfTotal": "전체의 {percent}%",
"shareUnderOne": "전체의 1% 미만",
"subtitle": "접근할 수 있는 가장 큰 스트림",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "열기",
"openAria": {
"logs": "로그 열기",
"metrics": "메트릭 열기",
"traces": "트레이스 열기"
},
"logs": "로그",
"metrics": "메트릭",
"traces": "트레이스",
"streams": "스트림",
"events": "이벤트",
"samples": "샘플",
"spans": "스팬",
"stored": "데이터 크기"
},
"storage": {
"emptyTitle": "아직 데이터가 없습니다",
"bySignal": "시그널별 데이터 크기",
"splitNote": "로그, 메트릭, 트레이스의 비압축 크기",
"used": "데이터 크기"
},
"limit": {
"used": "오늘 수집량(UTC)",
"ofLimit": "{limit} 중",
"daysOver": "이번 달 한도 초과 일수",
"approachingLimit": "한도 근접",
"overLimit": "한도 초과",
"ofUnlimited": "무제한",
"unavailable": "수집 한도를 사용할 수 없음",
"blockNote": "한도 초과 일수가 {allowed}일을 초과하면 검색이 차단됩니다 | 한도 초과 일수가 {allowed}일을 초과하면 검색이 차단됩니다",
"daysOverOf": "{allowed}일 중 {days}일",
"expiredLine": "라이선스 만료됨",
"resets": "00:00 UTC에 초기화됩니다. {countdown} 후",
"subtitle": "일일 한도는 롤링 24시간이 아니라 UTC 기준 하루를 집계합니다"
},
"empty": {
"incidents": "미해결 인시던트 없음",
"anomalies": "선택한 시간 범위에서 이상 징후가 감지되지 않았습니다",
"anomalyNoChecks": "이상 감지 검사가 설정되어 있지 않습니다",
"services": "선택한 시간 범위에 서비스 데이터가 없습니다",
"recentEvents": "선택한 시간 범위에 알림 이벤트가 없습니다"
},
"stale": {
"message": "{time} 기준 데이터를 표시하고 있습니다. 새로고침에 실패했습니다.",
"retry": "다시 시도"
},
"dataAsOf": "{time} 기준 데이터",
"status": {
"attention": "주의 필요",
"checking": "확인 중…",
"partial": "일부 검사를 불러오지 못했습니다",
"clear": "주의가 필요한 항목이 없습니다",
"items": {
"firing": "{count}개 알림 발생 | {count}개 알림 발생",
"failing": "{count}개 규칙 실패 | {count}개 규칙 실패",
"incidents": "{count}개 미해결 인시던트 | {count}개 미해결 인시던트",
"pipelines": "{count}개 파이프라인에서 오류 발생 | {count}개 파이프라인에서 오류 발생",
"quiet": "{count}개 활동 없는 스트림 | {count}개 활동 없는 스트림"
},
"unmonitored": "아직 모니터링 중인 항목이 없습니다"
},
"timeRange": {
"heading": "선택한 시간 범위 내"
}
},
"chatHistory": {
"title": "채팅",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "수집기 {count}개를 재시작하시겠습니까?",
"restartMessage": "각 수집기는 서버의 다음 메시지를 받을 때 재시작됩니다. 각 호스트에서 데이터 수집이 잠시 중단됩니다.",
"restartQueued": "수집기 {count}개에 재시작이 요청되었습니다 | 수집기 {count}개에 재시작이 요청되었습니다",
"restartOffline": "이 컬렉터는 오프라인 상태입니다. 다시 온라인이 되면 재시작하세요.",
"restartUnsupported": "이 수집기는 재시작 명령을 지원하지 않습니다",
"systemSections": "시스템 관리 섹션",
"systemSectionsCaption": "슈퍼바이저와 서버가 설정하는 OpAMP 확장 및 서비스 텔레메트리",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "{config} 편집기를 엽니다. 수정된 버전을 게시하고 이 플릿을 해당 버전으로 변경하면 이 롤아웃은 대체됩니다.",
"confirmResume": "동일한 롤아웃이 {n}단계부터 계속됩니다.",
"confirmRollback": "안정 버전인 {config} v{version}(으)로 즉시 롤백하는 롤아웃을 시작합니다. 이미 해당 버전인 수집기에는 아무것도 전달되지 않습니다.",
"confirmRollbackNoStable": "이 플릿에는 안정 버전이 없으므로 롤아웃이 종료되고, 다시 배포할 때까지 해당 컬렉터의 수집이 중단됩니다.",
"confirmRollbackNoStable": "안정 버전으로 즉시 롤백하는 롤아웃을 시작합니다.",
"confirmSkip": "실패한 수집기 {count}개를 제외하고 계속합니다. 제외 표시되며 다음 롤아웃에 다시 포함됩니다. | 실패한 수집기 {count}개를 제외하고 계속합니다. 제외 표시되며 다음 롤아웃에 다시 포함됩니다.",
"confirmed": "확인됨",
"eventConverged": "{n}단계 수렴 완료",
@@ -29358,7 +29479,6 @@
"phaseN": "{n}단계",
"phaseTimedOut": "시간 초과",
"phaseTimeoutNote": "{minutes}분 안에 확인도 거부도 하지 않는 단계가 있으면 롤아웃이 중단됩니다 (플릿 설정)",
"phaseTimeoutNoteSeconds": "{minutes}분 {seconds}초 내에 확인도 거부도 하지 않는 단계가 있으면 롤아웃이 중단됩니다(플릿 설정)",
"phaseVerifying": "검증 중",
"phases": "단계",
"planEmpty": "온라인 상태인 컬렉터가 없어 하나가 연결될 때까지 롤아웃이 대기합니다",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "상속 ({value})",
"offlineHeartbeats": "오프라인으로 간주하는 하트비트 누락 횟수",
"offlineHeartbeatsHelp": "메시지 없이 이 횟수만큼 하트비트 간격이 지나면 수집기는 오프라인입니다",
"offlineRatio": "오프라인 비율 (%)",
"offlineRatioHelp": "컬렉터 중 이 비율을 초과하는 수가 오프라인이 되면 해당 단계가 중단됩니다",
"offlineRatio": "오프라인 비율 이벤트 (%)",
"offlineRatioHelp": "이 비율을 넘는 수집기가 오프라인이면 플릿 이벤트를 기록합니다",
"outOfRange": "{min}에서 {max} 사이의 숫자를 입력하세요",
"phaseTimeout": "단계 제한 시간 (초)",
"phaseTimeoutHelp": "이 시간 안에 확인도 거부도 하지 않는 롤아웃 단계는 중단됩니다",
+170 -50
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Niet in je licentie",
"title": "{feature} is niet inbegrepen in je licentie",
"description": "Je beheerder kan het toevoegen door de licentie bij te werken, of neem contact op met sales voor een licentie die het bevat.",
"thisFeature": "Deze functie",
"contactSales": "Contact opnemen met sales",
"toastTitle": "Niet inbegrepen in je licentie",
"toastMessage": "Je licentie bevat niet: {features}. Vraag je beheerder de licentie bij te werken.",
"toastMessageGeneric": "Je licentie bevat deze functie niet. Vraag je beheerder de licentie bij te werken.",
"bannerMessage": "Deze functie is vergrendeld. Er is de functie {feature} in je licentie voor nodig.",
"bannerMessageGeneric": "Deze pagina vereist een functie die niet in je licentie zit.",
"headerLocked": "Vergrendeld: {feature} vereist"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Nog geen geschiedenis",
@@ -3420,22 +3407,6 @@
"contact_name": "Naam van de contactpersoon",
"contact_email": "E-mailadres voor contact",
"environment_type": "Omgevingstype",
"features": "Functies",
"features_all_unlocked": "Je licentie ontgrendelt alle functies.",
"features_summary": "Je licentie ontgrendelt {ratio}. {free} gratis functie is altijd inbegrepen. | Je licentie ontgrendelt {ratio}. {free} gratis functies zijn altijd inbegrepen.",
"features_paid_ratio": "{unlocked} van {paid} betaalde functie | {unlocked} van {paid} betaalde functies",
"features_unlocked": "Ontgrendeld",
"features_missing": "Niet in je licentie",
"features_request_access": "Toegang aanvragen",
"features_search": "Zoek in {count} functie | Zoek in alle {count} functies",
"features_filter_all": "Alle",
"features_filter_free": "Gratis",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Geen functies gevonden voor je zoekopdracht",
"features_none": "Geen functies in deze groep",
"features_free_heading": "Gratis, inbegrepen bij elke licentie",
"features_new": "Nieuw sinds je licentie",
"features_filter_new": "Nieuw",
"request_new_license": "Nieuwe licentie aanvragen",
"add_new_license_key": "Nieuwe licentiecode toevoegen",
"refresh_license_limits": "Licentielimieten vernieuwen",
@@ -9915,7 +9886,7 @@
"alertsSuffix": "alarmen",
"viewingLabel": "— bekijken",
"timesSymbol": "×",
"activeAnomalies": "Actieve afwijkingen",
"activeAnomalies": "Actieve anomalieën",
"activeIncidents": "Actieve incidenten",
"services": "Diensten",
"recentEvents": "Recente gebeurtenissen",
@@ -9927,34 +9898,185 @@
"colReqs": "Req's",
"scrollLeft": "Services naar links scrollen",
"scrollRight": "Services naar rechts scrollen",
"untitledIncident": "Incident zonder titel",
"allClear": "Alles duidelijk",
"allClearDesc": "Geen actieve afwijkingen, incidenten of slechte services in het geselecteerde tijdvenster.",
"allClearDescScoped": "Geen open incidenten, afwijkingen of gedegradeerde services in het geselecteerde tijdsbereik.",
"allClearPartial": "Niets te melden",
"allClearPartialDesc": "Niets vereist aandacht. We hebben {areas} niet gecontroleerd, omdat je licentie die functies niet omvat.",
"areaAlerts": "Alarmen",
"areaAnomalies": "Anomaliedetectie",
"areaIncidents": "Incidenten",
"areaServices": "Services",
"untitledIncident": "Naamloos incident",
"notMonitoredTitle": "Er wordt nog niets bewaakt",
"notMonitoredDesc": "Je gegevens komen binnen, maar er wordt geen alarm- of afwijkingscontrole op uitgevoerd. Voeg er een toe en Overzicht toont alles wat aandacht nodig heeft.",
"notMonitoredAlert": "Een alarm aanmaken",
"notMonitoredAlertDesc": "Ontvang een melding wanneer een query een drempelwaarde overschrijdt",
"notMonitoredAnomaly": "Afwijkingsdetectie instellen",
"notMonitoredAnomalyDesc": "Signaleer ongebruikelijke volumes zonder een drempelwaarde te kiezen",
"emptyActionAlerts": "Alarmregels bekijken",
"emptyActionAlertsDesc": "Bekijk de geconfigureerde alarmen en drempels",
"emptyActionLogs": "Logs bekijken",
"emptyActionLogsDesc": "Zoek en filter je logstreams",
"emptyActionTraces": "Traces verkennen",
"emptyActionTracesDesc": "Inspecteer gedistribueerde traces en service-latentie",
"anomalyDetectedTitle": "Anomalie gedetecteerd — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} afwijkend(e) punt(en)",
"secondsAgo": "{count} sec geleden",
"minutesAgo": "{count} min geleden",
"hoursAgo": "{count} uur geleden",
"daysAgo": "{count} d geleden"
"daysAgo": "{count} d geleden",
"cards": {
"firingAlerts": "Afgaande alarmen",
"failingRules": "Falende regels",
"openIncidents": "Open incidenten",
"pipelineErrors": "Pipelinefouten",
"pipelinesOf": "van {enabled} ingeschakeld",
"quietStreams": "Stille streams",
"firingNone": "Geen afgaande",
"noAlertRules": "Geen alarmregels",
"noEnabledRules": "Geen ingeschakelde regels",
"failingNone": "Geen falende",
"failingNote": "Laatste evaluatie gaf een fout",
"incidentsNone": "Geen open",
"noPipelines": "Geen pipelines",
"noStreams": "Geen streams",
"quietNote": "Geen nieuwe data in de afgelopen 7 dagen",
"criticalUnit": "{count} op kritiek niveau",
"deliveryFailures": "Fouten bij alarmlevering",
"deliveryNote": "Alarmen die afgingen maar geen melding konden versturen",
"oldestFiring": "Langst afgaand: {age}"
},
"state": {
"tileForbidden": "Geen toegang",
"tileLocked": "Vergrendeld",
"tileError": "Kon niet laden",
"tileUnavailable": "Niet beschikbaar",
"sectionLocked": "Beschikbaar met een enterprise-licentie",
"sectionForbidden": "Je rol kan deze gegevens niet zien. Vraag een org-beheerder om toegang."
},
"time": {
"minutes": "{minutes}m",
"hoursMinutes": "{hours}u {minutes}m",
"daysHours": "{days}d {hours}u"
},
"level": {
"critical": "Kritiek",
"warning": "Waarschuwing",
"okFiring": "Afgaand"
},
"sections": {
"alerts": "Alarmen",
"dataHealth": "Gezondheid van gegevens",
"alertsAndIncidents": "Alarmen en incidenten",
"limitAndStorage": "Ingestielimiet en opslag",
"storage": "Gegevensgrootte",
"topStreams": "Top streams op gegevensgrootte"
},
"alerts": {
"firingFor": "Afgaand gedurende",
"showMore": "Nog {count} afgaand alarm tonen | Nog {count} afgaande alarmen tonen",
"emptyTitle": "Nog geen alarmregels",
"emptyDesc": "Regels die je maakt verschijnen hier, met wat er per niveau afgaat en wat er niet kan worden uitgevoerd.",
"emptyAction": "Een alarm maken",
"criticalLevel": "Kritiek niveau",
"warningLevel": "Waarschuwingsniveau",
"rulesFailing": "Falende regels",
"firingNow": "Nu afgaand, oudste eerst",
"noneFiring": "Er gaan nu geen alarmen af",
"rulesActive": "{count} actieve regel | {count} actieve regels",
"rulesFailingToEvaluate": "{count} kan niet worden geëvalueerd | {count} kunnen niet worden geëvalueerd",
"subtitle": "{active} · {failing}",
"viewAll": "Alle alarmen bekijken",
"rulesEvaluated": "{count} regel wordt geëvalueerd | {count} regels worden geëvalueerd"
},
"dataHealth": {
"viewPipelines": "Pipelines bekijken",
"noPipelines": "Nog geen pipelines.",
"pipelinesErrored": "Pipelines met fouten",
"pipelinesNote": "Ingeschakelde pipelines met een fout",
"quiet": "Streams zonder recente data",
"quietNote": "Gestopt met schrijven tussen een uur en 7 dagen geleden",
"failing": "Alarmregels die niet kunnen worden geëvalueerd",
"failingNote": "Blinde vlekken, gaan niet af",
"delivery": "Mislukte leveringen van alarmen",
"deliveryNote": "Alarmen die afgingen maar geen melding konden versturen",
"schema": "Schemawijzigingen in 24 uur",
"schemaNote": "Nieuwe schemaversies in je streams",
"status": {
"needsAction": "Actie nodig",
"warning": "Waarschuwing",
"healthy": "Gezond",
"notSetUp": "Niet ingesteld",
"info": "Info"
},
"compaction": "Openstaande compactietaken",
"compactionNote": "Over alle streamtypes die je kunt zien",
"pipelinesActive": "Van {count} actieve pipeline | Van {count} actieve pipelines",
"subtitle": "Komt data aan, intact en afgeleverd?"
},
"streams": {
"emptyTitle": "Nog geen streams",
"emptyDesc": "Streams verschijnen hier zodra er data binnenkomt.",
"startIngesting": "Start ingestie",
"allStreams": "Alle streams",
"shareOfTotal": "{percent}% van het totaal",
"shareUnderOne": "Minder dan 1% van het totaal",
"subtitle": "Grootste streams waartoe je toegang hebt",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Openen",
"openAria": {
"logs": "Logs openen",
"metrics": "Metrieken openen",
"traces": "Traces openen"
},
"logs": "Logs",
"metrics": "Metrieken",
"traces": "Traces",
"streams": "Streams",
"events": "Gebeurtenissen",
"samples": "Samples",
"spans": "Spans",
"stored": "Gegevensgrootte"
},
"storage": {
"emptyTitle": "Nog geen gegevens",
"bySignal": "Gegevensgrootte per signaal",
"splitNote": "Niet-gecomprimeerde grootte van logs, metrieken en traces",
"used": "Gegevensgrootte"
},
"limit": {
"used": "Vandaag geïngesteerd (UTC)",
"ofLimit": "van {limit}",
"daysOver": "Dagen over de limiet deze maand",
"approachingLimit": "Nadert de limiet",
"overLimit": "Over de limiet",
"ofUnlimited": "van onbeperkt",
"unavailable": "Ingestielimiet niet beschikbaar",
"blockNote": "Zoeken wordt geblokkeerd na meer dan {allowed} dag over de limiet | Zoeken wordt geblokkeerd na meer dan {allowed} dagen over de limiet",
"daysOverOf": "{days} van {allowed} toegestaan",
"expiredLine": "Licentie verlopen",
"resets": "Reset om 00:00 UTC, over {countdown}",
"subtitle": "De daglimiet telt de UTC-dag, niet een rollend etmaal van 24 uur"
},
"empty": {
"incidents": "Geen open incidenten",
"anomalies": "Geen afwijkingen gedetecteerd in het geselecteerde tijdsbereik",
"anomalyNoChecks": "Er zijn geen anomaliecontroles ingesteld",
"services": "Geen servicegegevens in het geselecteerde tijdsbereik",
"recentEvents": "Geen alarmgebeurtenissen in het geselecteerde tijdsbereik"
},
"stale": {
"message": "Gegevens van {time} worden getoond. Vernieuwen mislukt.",
"retry": "Opnieuw proberen"
},
"dataAsOf": "Gegevens per {time}",
"status": {
"attention": "Vraagt aandacht",
"checking": "Controleren…",
"partial": "Sommige controles konden niet worden geladen",
"clear": "Niets vraagt aandacht",
"items": {
"firing": "{count} alarm dat afgaat | {count} alarms die afgaan",
"failing": "{count} regel die faalt | {count} regels die falen",
"incidents": "{count} open incident | {count} open incidenten",
"pipelines": "{count} pipeline met fouten | {count} pipelines met fouten",
"quiet": "{count} stille stream | {count} stille streams"
},
"unmonitored": "Er wordt nog niets gemonitord"
},
"timeRange": {
"heading": "In het geselecteerde tijdsbereik"
}
},
"chatHistory": {
"title": "Chats",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "{count} collectors herstarten?",
"restartMessage": "Elke collector herstart bij zijn volgende bericht van de server. Het verzamelen van gegevens pauzeert kort op elke host.",
"restartQueued": "Herstart aangevraagd voor {count} collector | Herstart aangevraagd voor {count} collectors",
"restartOffline": "Deze collector is offline. Herstart hem zodra hij weer online is.",
"restartUnsupported": "Deze collector accepteert geen herstartopdrachten",
"systemSections": "Door het systeem beheerde secties",
"systemSectionsCaption": "OpAMP-extensie en servicetelemetrie, ingesteld door de supervisor en de server",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Opent de editor voor {config}. Publiceer een gecorrigeerde versie en zet deze fleet daarop; deze uitrol wordt dan vervangen.",
"confirmResume": "Dezelfde uitrol gaat verder vanaf fase {n}.",
"confirmRollback": "Start direct een uitrol terug naar de stabiele versie, {config} v{version}. Collectors die er al op staan, ontvangen niets.",
"confirmRollbackNoStable": "Deze fleet heeft geen stabiele versie, dus de uitrol eindigt en de collectors stoppen met verzamelen totdat je opnieuw deployt.",
"confirmRollbackNoStable": "Start direct een uitrol terug naar de stabiele versie.",
"confirmSkip": "Sluit {count} falende collector uit en gaat verder. Deze wordt als uitgesloten gemarkeerd en bij de volgende uitrol opnieuw meegenomen. | Sluit {count} falende collectors uit en gaat verder. Ze worden als uitgesloten gemarkeerd en bij de volgende uitrol opnieuw meegenomen.",
"confirmed": "Bevestigd",
"eventConverged": "Fase {n} geconvergeerd",
@@ -29358,7 +29479,6 @@
"phaseN": "Fase {n}",
"phaseTimedOut": "Verlopen",
"phaseTimeoutNote": "Een fase die binnen {minutes} min niet bevestigt of weigert, stopt de uitrol (fleet-instelling)",
"phaseTimeoutNoteSeconds": "Een fase die niet binnen {minutes} min {seconds} s bevestigt of afwijst, stopt de uitrol (fleetinstelling)",
"phaseVerifying": "Verifiëren",
"phases": "Fasen",
"planEmpty": "Er is geen collector online, dus de uitrol wacht tot er één verbinding maakt",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Standaardwaarde ({value})",
"offlineHeartbeats": "Offline na gemiste heartbeats",
"offlineHeartbeatsHelp": "Een collector is offline na zoveel heartbeat-intervallen zonder bericht",
"offlineRatio": "Offline-ratio (%)",
"offlineRatioHelp": "Stopt de fase wanneer meer dan dit aandeel van de collectors offline is",
"offlineRatio": "Offline-aandeelgebeurtenis (%)",
"offlineRatioHelp": "Legt een fleet-gebeurtenis vast wanneer meer dan dit aandeel van de collectors offline is",
"outOfRange": "Voer een getal van {min} tot {max} in",
"phaseTimeout": "Fasetime-out (seconden)",
"phaseTimeoutHelp": "Een uitrolfase die in deze tijd niet bevestigt of weigert, stopt",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Brak w licencji",
"title": "{feature} nie jest objęta Twoją licencją",
"description": "Administrator może ją dodać, aktualizując licencję. Możesz też skontaktować się z działem sprzedaży, aby uzyskać licencję, która ją obejmuje.",
"thisFeature": "Ta funkcja",
"contactSales": "Skontaktuj się z działem sprzedaży",
"toastTitle": "Nieobjęte licencją",
"toastMessage": "Twoja licencja nie obejmuje: {features}. Poproś administratora o aktualizację licencji.",
"toastMessageGeneric": "Twoja licencja nie obejmuje tej funkcji. Poproś administratora o aktualizację licencji.",
"bannerMessage": "Ta funkcja jest zablokowana. Wymaga funkcji {feature} w Twojej licencji.",
"bannerMessageGeneric": "Ta strona wymaga funkcji, której nie ma w Twojej licencji.",
"headerLocked": "Zablokowano: wymaga {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Brak historii",
@@ -3420,22 +3407,6 @@
"contact_name": "Imię i nazwisko kontaktowe",
"contact_email": "Adres e-mail kontaktowy",
"environment_type": "Typ środowiska",
"features": "Funkcje",
"features_all_unlocked": "Twoja licencja odblokowuje wszystkie funkcje.",
"features_summary": "Twoja licencja odblokowuje {ratio}. Zawsze wliczona jest {free} bezpłatna funkcja. | Twoja licencja odblokowuje {ratio}. Zawsze wliczane są bezpłatne funkcje ({free}).",
"features_paid_ratio": "{unlocked} z {paid} płatnej funkcji | {unlocked} z {paid} płatnych funkcji",
"features_unlocked": "Odblokowane",
"features_missing": "Nieobjęte licencją",
"features_request_access": "Poproś o dostęp",
"features_search": "Przeszukaj {count} funkcję | Przeszukaj wszystkie funkcje ({count})",
"features_filter_all": "Wszystkie",
"features_filter_free": "Bezpłatne",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Brak funkcji pasujących do wyszukiwania",
"features_none": "Brak funkcji w tej grupie",
"features_free_heading": "Bezpłatne, wliczone w każdą licencję",
"features_new": "Nowe po Twojej licencji",
"features_filter_new": "Nowe",
"request_new_license": "Poproś o nową licencję",
"add_new_license_key": "Dodaj nowy klucz licencyjny",
"refresh_license_limits": "Odśwież limity licencji",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Przewiń usługi w lewo",
"scrollRight": "Przewiń usługi w prawo",
"untitledIncident": "Incydent bez tytułu",
"allClear": "Wszystkie systemy sprawne",
"allClearDesc": "Brak aktywnych anomalii, incydentów lub usług w stanie obniżonej wydajności w wybranym przedziale czasu. Twoja infrastruktura wygląda zdrowo.",
"allClearDescScoped": "Brak otwartych incydentów, anomalii lub zdegradowanych usług w wybranym zakresie czasu.",
"allClearPartial": "Nic do zgłoszenia",
"allClearPartialDesc": "Nic nie wymaga uwagi. Nie sprawdziliśmy {areas}, ponieważ Twoja licencja nie obejmuje tych funkcji.",
"areaAlerts": "Alerty",
"areaAnomalies": "Wykrywanie anomalii",
"areaIncidents": "Incydenty",
"areaServices": "Usługi",
"notMonitoredTitle": "Nic nie jest jeszcze monitorowane",
"notMonitoredDesc": "Twoje dane docierają, ale nie działa na nich żaden alert ani kontrola anomalii. Dodaj go, a Przegląd będzie wyświetlać wszystko, co wymaga uwagi.",
"notMonitoredAlert": "Utwórz alert",
"notMonitoredAlertDesc": "Otrzymuj powiadomienia, gdy zapytanie przekroczy próg",
"notMonitoredAnomaly": "Skonfiguruj wykrywanie anomalii",
"notMonitoredAnomalyDesc": "Oznaczaj nietypowy wolumen bez ustalania progu",
"emptyActionAlerts": "Zobacz reguły alertów",
"emptyActionAlertsDesc": "Przejrzyj skonfigurowane alerty i progi",
"emptyActionLogs": "Eksploruj logi",
"emptyActionLogsDesc": "Wyszukaj i filtruj swoje strumienie logów",
"emptyActionTraces": "Przeglądaj trace",
"emptyActionTracesDesc": "Przeglądaj trace rozproszone i opóźnienia usług",
"anomalyDetectedTitle": "Wykryto anomalię — {name}",
"anomalyDetectedDescription": "Strumień: {stream} · {count} pkt. anomalii",
"secondsAgo": "{count} s temu",
"minutesAgo": "{count} min temu",
"hoursAgo": "{count} godz. temu",
"daysAgo": "{count} d. temu"
"daysAgo": "{count} d. temu",
"cards": {
"firingAlerts": "Wyzwolone alerty",
"failingRules": "Reguły z błędami",
"openIncidents": "Otwarte incydenty",
"pipelineErrors": "Błędy potoku",
"pipelinesOf": "z {enabled} włączonych",
"quietStreams": "Ciche strumienie",
"firingNone": "Brak wyzwolonych",
"noAlertRules": "Brak reguł alertów",
"noEnabledRules": "Brak włączonych reguł",
"failingNone": "Brak błędów",
"failingNote": "Ostatnia ewaluacja zakończyła się błędem",
"incidentsNone": "Brak otwartych",
"noPipelines": "Brak potoków",
"noStreams": "Brak strumieni",
"quietNote": "Zaprzestały zapisu w ciągu ostatnich 7 dni",
"criticalUnit": "{count} na poziomie krytycznym",
"deliveryFailures": "Błędy dostarczania alertów",
"deliveryNote": "Alerty, które zostały wyzwolone, ale nie mogły powiadomić",
"oldestFiring": "Najstarszy wyzwolony od {age}"
},
"state": {
"tileForbidden": "Brak dostępu",
"tileLocked": "Zablokowane",
"tileError": "Nie można załadować",
"tileUnavailable": "Niedostępne",
"sectionLocked": "Dostępne w licencji enterprise",
"sectionForbidden": "Twoja rola nie może wyświetlić tych danych. Poproś administratora organizacji o dostęp."
},
"time": {
"minutes": "{minutes} min",
"hoursMinutes": "{hours} godz. {minutes} min",
"daysHours": "{days} dni {hours} godz."
},
"level": {
"critical": "Krytyczny",
"warning": "Ostrzeżenie",
"okFiring": "Wyzwolony"
},
"sections": {
"alerts": "Alerty",
"dataHealth": "Kondycja danych",
"alertsAndIncidents": "Alerty i incydenty",
"limitAndStorage": "Limit pozyskiwania danych i przechowywanie",
"storage": "Rozmiar danych",
"topStreams": "Największe strumienie według rozmiaru danych"
},
"alerts": {
"firingFor": "Wyzwolony od",
"showMore": "Pokaż jeszcze {count} wyzwolony alert | Pokaż jeszcze {count} wyzwolonych alertów",
"emptyTitle": "Nie ma jeszcze reguł alertów",
"emptyDesc": "Utworzone reguły pojawią się tutaj wraz z informacją, co jest wyzwolone według poziomu i co nie działa.",
"emptyAction": "Utwórz alert",
"criticalLevel": "Poziom krytyczny",
"warningLevel": "Poziom ostrzeżenia",
"rulesFailing": "Reguły z błędami",
"firingNow": "Wyzwolone teraz, od najstarszych",
"noneFiring": "Brak obecnie wyzwolonych alertów",
"rulesActive": "{count} aktywna reguła | {count} aktywnych reguł",
"rulesFailingToEvaluate": "{count} z błędem ewaluacji | {count} z błędem ewaluacji",
"subtitle": "{active} · {failing}",
"viewAll": "Zobacz wszystkie alerty",
"rulesEvaluated": "{count} reguła jest ewaluowana | {count} reguł jest ewaluowanych"
},
"dataHealth": {
"viewPipelines": "Zobacz potoki",
"noPipelines": "Brak potoków.",
"pipelinesErrored": "Potoki z błędami",
"pipelinesNote": "Włączone potoki z błędem",
"quiet": "Strumienie bez napływu danych",
"quietNote": "Zapis zatrzymany od godziny do 7 dni temu",
"failing": "Reguły alertów z błędem ewaluacji",
"failingNote": "Martwe punkty, brak wyzwolenia",
"delivery": "Błędy dostarczania alertów",
"deliveryNote": "Alerty wyzwolone, których nie udało się powiadomić",
"schema": "Zmiany schematu w ciągu 24 godz.",
"schemaNote": "Nowe wersje schematu w Twoich strumieniach",
"status": {
"needsAction": "Wymaga działania",
"warning": "Ostrzeżenie",
"healthy": "Prawidłowy",
"notSetUp": "Nie skonfigurowano",
"info": "Informacja"
},
"compaction": "Oczekujące zadania kompakcji",
"compactionNote": "W typach strumieni, które widzisz",
"pipelinesActive": "Z {count} aktywnego potoku | Z {count} aktywnych potoków",
"subtitle": "Czy dane docierają, są kompletne i dostarczane?"
},
"streams": {
"emptyTitle": "Brak strumieni",
"emptyDesc": "Strumienie pojawią się tutaj, gdy zaczną napływać dane.",
"startIngesting": "Rozpocznij pozyskiwanie danych",
"allStreams": "Wszystkie strumienie",
"shareOfTotal": "{percent}% całości",
"shareUnderOne": "Poniżej 1% całości",
"subtitle": "Największe strumienie, do których masz dostęp",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Otwórz",
"openAria": {
"logs": "Otwórz logi",
"metrics": "Otwórz metryki",
"traces": "Otwórz trace"
},
"logs": "Logi",
"metrics": "Metryki",
"traces": "Trace",
"streams": "Strumienie",
"events": "Zdarzenia",
"samples": "Próbki",
"spans": "Spany",
"stored": "Rozmiar danych"
},
"storage": {
"emptyTitle": "Brak danych",
"bySignal": "Rozmiar danych według sygnału",
"splitNote": "Rozmiar nieskompresowany dla logów, metryk i trace",
"used": "Rozmiar danych"
},
"limit": {
"used": "Pozyskano dzisiaj (UTC)",
"ofLimit": "z {limit}",
"daysOver": "Dni z przekroczeniem limitu w tym miesiącu",
"approachingLimit": "Zbliżanie się do limitu",
"overLimit": "Przekroczono limit",
"ofUnlimited": "z nielimitowanym",
"unavailable": "Limit pozyskiwania danych niedostępny",
"blockNote": "Wyszukiwanie jest blokowane po przekroczeniu limitu o więcej niż {allowed} dzień | Wyszukiwanie jest blokowane po przekroczeniu limitu o więcej niż {allowed} dni",
"daysOverOf": "{days} z {allowed} dozwolonych",
"expiredLine": "Licencja wygasła",
"resets": "Reset o 00:00 UTC, za {countdown}",
"subtitle": "Limit dzienny obejmuje dzień UTC, a nie przesuwne 24 godziny"
},
"empty": {
"incidents": "Brak otwartych incydentów",
"anomalies": "Nie wykryto anomalii w wybranym zakresie czasu",
"anomalyNoChecks": "Nie skonfigurowano kontroli anomalii",
"services": "Brak danych o usługach w wybranym zakresie czasu",
"recentEvents": "Brak zdarzeń alertów w wybranym zakresie czasu"
},
"stale": {
"message": "Wyświetlane dane z {time}. Odświeżanie nie powiodło się.",
"retry": "Ponów"
},
"dataAsOf": "Dane na {time}",
"status": {
"attention": "Wymaga uwagi",
"checking": "Sprawdzanie…",
"partial": "Nie udało się wczytać niektórych kontroli",
"clear": "Nic nie wymaga uwagi",
"items": {
"firing": "{count} alert wyzwolony | {count} wyzwolonych alertów",
"failing": "{count} reguła z błędem | {count} reguł z błędem",
"incidents": "{count} otwarty incydent | {count} otwartych incydentów",
"pipelines": "{count} potok z błędami | {count} potoków z błędami",
"quiet": "{count} nieaktywny strumień | {count} nieaktywnych strumieni"
},
"unmonitored": "Nic nie jest jeszcze monitorowane"
},
"timeRange": {
"heading": "W wybranym zakresie czasu"
}
},
"chatHistory": {
"title": "Czaty",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Uruchomić ponownie kolektory ({count})?",
"restartMessage": "Każdy kolektor uruchomi się ponownie po następnej wiadomości z serwera. Zbieranie danych na każdym hoście na chwilę się wstrzyma.",
"restartQueued": "Zażądano restartu kolektora: {count} | Zażądano restartu kolektorów: {count}",
"restartOffline": "Ten kolektor jest offline. Uruchom go ponownie, gdy znów będzie dostępny.",
"restartUnsupported": "Ten kolektor nie przyjmuje poleceń restartu",
"systemSections": "Sekcje zarządzane przez system",
"systemSectionsCaption": "Rozszerzenie OpAMP i telemetria usługi, ustawiane przez supervisora i serwer",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Otwiera edytor {config}. Opublikuj poprawioną wersję i ustaw ją dla tej floty; to wdrożenie zostanie wtedy zastąpione.",
"confirmResume": "To samo wdrożenie jest kontynuowane od fazy {n}.",
"confirmRollback": "Uruchamia natychmiastowe wdrożenie z powrotem do wersji stabilnej, {config} v{version}. Kolektory, które już ją mają, nic nie otrzymają.",
"confirmRollbackNoStable": "Ta flota nie ma stabilnej wersji, więc wdrożenie kończy się, a jej kolektory przestają zbierać dane, dopóki nie wdrożysz ponownie.",
"confirmRollbackNoStable": "Uruchamia natychmiastowe wdrożenie z powrotem do wersji stabilnej.",
"confirmSkip": "Wyklucza kolektor z błędem ({count}) i kontynuuje. Zostaje oznaczony jako wykluczony i ponownie uwzględniony w następnym wdrożeniu. | Wyklucza kolektory z błędami ({count}) i kontynuuje. Zostają oznaczone jako wykluczone i ponownie uwzględnione w następnym wdrożeniu.",
"confirmed": "Potwierdzono",
"eventConverged": "Faza {n} osiągnęła stan docelowy",
@@ -29358,7 +29479,6 @@
"phaseN": "Faza {n}",
"phaseTimedOut": "Przekroczono czas",
"phaseTimeoutNote": "Faza, która nie potwierdzi ani nie odrzuci w ciągu {minutes} min, wstrzymuje wdrożenie (ustawienie floty)",
"phaseTimeoutNoteSeconds": "Faza, która w ciągu {minutes} min {seconds} s ani nie potwierdzi, ani nie odrzuci, wstrzymuje wdrożenie (ustawienie floty)",
"phaseVerifying": "Weryfikacja",
"phases": "Fazy",
"planEmpty": "Żaden kolektor nie jest online, więc wdrożenie poczeka, aż któryś się połączy",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Dziedzicz ({value})",
"offlineHeartbeats": "Offline po pominiętych heartbeatach",
"offlineHeartbeatsHelp": "Kolektor jest offline po tylu interwałach heartbeatu bez wiadomości",
"offlineRatio": "Udział offline (%)",
"offlineRatioHelp": "Wstrzymuje fazę, gdy odsetek jej kolektorów offline przekroczy tę wartość",
"offlineRatio": "Zdarzenie odsetka offline (%)",
"offlineRatioHelp": "Zapisuje zdarzenie floty, gdy więcej niż ten odsetek kolektorów jest offline",
"outOfRange": "Wpisz liczbę od {min} do {max}",
"phaseTimeout": "Limit czasu fazy (sekundy)",
"phaseTimeoutHelp": "Faza wdrożenia, która nie potwierdzi ani nie odrzuci w tym czasie, zostaje wstrzymana",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Não incluído na sua licença",
"title": "{feature} não está incluído na sua licença",
"description": "O seu administrador pode adicioná-lo atualizando a licença, ou contacte a equipa de vendas para obter uma licença que o inclua.",
"thisFeature": "Esta funcionalidade",
"contactSales": "Contactar vendas",
"toastTitle": "Não incluído na sua licença",
"toastMessage": "A sua licença não inclui: {features}. Peça ao seu administrador para atualizar a licença.",
"toastMessageGeneric": "A sua licença não inclui esta funcionalidade. Peça ao seu administrador para atualizar a licença.",
"bannerMessage": "Esta funcionalidade está bloqueada. Necessita da funcionalidade {feature} na sua licença.",
"bannerMessageGeneric": "Esta página necessita de uma funcionalidade que não está na sua licença.",
"headerLocked": "Bloqueado: necessita de {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Sem histórico ainda",
@@ -3420,22 +3407,6 @@
"contact_name": "Nome do contacto",
"contact_email": "E-mail de contacto",
"environment_type": "Tipo de ambiente",
"features": "Funcionalidades",
"features_all_unlocked": "A sua licença desbloqueia todas as funcionalidades.",
"features_summary": "A sua licença desbloqueia {ratio}. {free} funcionalidade gratuita está sempre incluída. | A sua licença desbloqueia {ratio}. {free} funcionalidades gratuitas estão sempre incluídas.",
"features_paid_ratio": "{unlocked} de {paid} funcionalidade paga | {unlocked} de {paid} funcionalidades pagas",
"features_unlocked": "Desbloqueado",
"features_missing": "Não incluído na sua licença",
"features_request_access": "Pedir acesso",
"features_search": "Pesquisar {count} funcionalidade | Pesquisar todas as {count} funcionalidades",
"features_filter_all": "Todas",
"features_filter_free": "Gratuita",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Nenhuma funcionalidade corresponde à sua pesquisa",
"features_none": "Nenhuma funcionalidade neste grupo",
"features_free_heading": "Gratuita, incluída em todas as licenças",
"features_new": "Novas após a sua licença",
"features_filter_new": "Novas",
"request_new_license": "Solicitar nova licença",
"add_new_license_key": "Adicionar nova chave de licença",
"refresh_license_limits": "Atualizar limites de licença",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Deslocar serviços para a esquerda",
"scrollRight": "Deslocar serviços para a direita",
"untitledIncident": "Incidente sem título",
"allClear": "Tudo em ordem",
"allClearDesc": "Sem anomalias ativas, incidentes ou serviços degradados na janela de tempo selecionada. A sua infraestrutura parece estar em bom estado.",
"allClearDescScoped": "Nenhum incidente aberto, anomalia ou serviço degradado no intervalo de tempo selecionado.",
"allClearPartial": "Nada a assinalar",
"allClearPartialDesc": "Nada requer atenção. Não verificámos {areas}, porque a sua licença não inclui essas funcionalidades.",
"areaAlerts": "Alertas",
"areaAnomalies": "Deteção de anomalias",
"areaIncidents": "Incidentes",
"areaServices": "Serviços",
"notMonitoredTitle": "Ainda nada está a ser monitorizado",
"notMonitoredDesc": "Os seus dados estão a chegar, mas nenhum alerta ou verificação de anomalia é executado sobre eles. Adicione um e a Visão geral listará tudo o que precisa de atenção.",
"notMonitoredAlert": "Criar um alerta",
"notMonitoredAlertDesc": "Seja notificado quando uma consulta ultrapassar um limite",
"notMonitoredAnomaly": "Configurar a deteção de anomalias",
"notMonitoredAnomalyDesc": "Assinale volume invulgar sem definir um limite",
"emptyActionAlerts": "Ver regras de alerta",
"emptyActionAlertsDesc": "Reveja os alertas e limites configurados",
"emptyActionLogs": "Explorar logs",
"emptyActionLogsDesc": "Pesquise e filtre os seus streams de log",
"emptyActionTraces": "Explorar traces",
"emptyActionTracesDesc": "Inspecione traces distribuídos e latência de serviço",
"anomalyDetectedTitle": "Anomalia detetada — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} ponto(s) anómalo(s)",
"secondsAgo": "{count}s atrás",
"minutesAgo": "{count}m atrás",
"hoursAgo": "{count}h atrás",
"daysAgo": "{count}d atrás"
"daysAgo": "{count}d atrás",
"cards": {
"firingAlerts": "Alertas disparados",
"failingRules": "Regras com falhas",
"openIncidents": "Incidentes abertos",
"pipelineErrors": "Erros de pipeline",
"pipelinesOf": "de {enabled} ativos",
"quietStreams": "Streams sem atividade",
"firingNone": "Nenhum disparado",
"noAlertRules": "Sem regras de alerta",
"noEnabledRules": "Sem regras ativadas",
"failingNone": "Nenhuma com falhas",
"failingNote": "A última avaliação deu erro",
"incidentsNone": "Nenhum aberto",
"noPipelines": "Sem pipelines",
"noStreams": "Sem streams",
"quietNote": "Deixou de escrever nos últimos 7 dias",
"criticalUnit": "{count} em nível crítico",
"deliveryFailures": "Falhas de entrega de alertas",
"deliveryNote": "Alertas que dispararam mas não conseguiram notificar",
"oldestFiring": "Disparado há mais tempo: {age}"
},
"state": {
"tileForbidden": "Sem acesso",
"tileLocked": "Bloqueado",
"tileError": "Não foi possível carregar",
"tileUnavailable": "Não disponível",
"sectionLocked": "Disponível com uma licença enterprise",
"sectionForbidden": "A sua função não permite ver estes dados. Peça acesso a um administrador da organização."
},
"time": {
"minutes": "{minutes}m",
"hoursMinutes": "{hours}h {minutes}m",
"daysHours": "{days}d {hours}h"
},
"level": {
"critical": "Crítico",
"warning": "Aviso",
"okFiring": "Disparado"
},
"sections": {
"alerts": "Alertas",
"dataHealth": "Saúde dos dados",
"alertsAndIncidents": "Alertas e incidentes",
"limitAndStorage": "Limite de ingestão e armazenamento",
"storage": "Tamanho dos dados",
"topStreams": "Principais streams por tamanho dos dados"
},
"alerts": {
"firingFor": "Disparado há",
"showMore": "Mostrar mais {count} alerta disparado | Mostrar mais {count} alertas disparados",
"emptyTitle": "Ainda sem regras de alerta",
"emptyDesc": "As regras que criar aparecem aqui, com o que está disparado por nível e o que está a falhar na execução.",
"emptyAction": "Criar um alerta",
"criticalLevel": "Nível crítico",
"warningLevel": "Nível de aviso",
"rulesFailing": "Regras com falhas",
"firingNow": "Disparados agora, os mais antigos primeiro",
"noneFiring": "Nenhum alerta disparado neste momento",
"rulesActive": "{count} regra ativa | {count} regras ativas",
"rulesFailingToEvaluate": "{count} a falhar a avaliação | {count} a falhar a avaliação",
"subtitle": "{active} · {failing}",
"viewAll": "Ver todos os alertas",
"rulesEvaluated": "{count} regra está a ser avaliada | {count} regras estão a ser avaliadas"
},
"dataHealth": {
"viewPipelines": "Ver pipelines",
"noPipelines": "Ainda não há pipelines.",
"pipelinesErrored": "Pipelines com erros",
"pipelinesNote": "Pipelines ativos com um erro",
"quiet": "Streams sem atividade",
"quietNote": "Deixaram de escrever há entre uma hora e 7 dias",
"failing": "Regras de alerta com falha de avaliação",
"failingNote": "Pontos cegos, sem disparo",
"delivery": "Falhas de entrega de alertas",
"deliveryNote": "Alertas que dispararam mas cuja notificação falhou",
"schema": "Alterações de esquema nas últimas 24h",
"schemaNote": "Novas versões de esquema nos seus streams",
"status": {
"needsAction": "Requer ação",
"warning": "Aviso",
"healthy": "Saudável",
"notSetUp": "Não configurado",
"info": "Informação"
},
"compaction": "Tarefas de compactação pendentes",
"compactionNote": "Em todos os tipos de stream que consegue ver",
"pipelinesActive": "De {count} pipeline ativo | De {count} pipelines ativos",
"subtitle": "Os dados estão a chegar, intactos e entregues?"
},
"streams": {
"emptyTitle": "Ainda não há streams",
"emptyDesc": "Os streams aparecem aqui assim que os dados começarem a chegar.",
"startIngesting": "Começar a ingerir",
"allStreams": "Todos os streams",
"shareOfTotal": "{percent}% do total",
"shareUnderOne": "Menos de 1% do total",
"subtitle": "Os maiores streams a que tem acesso",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Abrir",
"openAria": {
"logs": "Abrir logs",
"metrics": "Abrir métricas",
"traces": "Abrir traces"
},
"logs": "Logs",
"metrics": "Métricas",
"traces": "Traces",
"streams": "Streams",
"events": "Eventos",
"samples": "Amostras",
"spans": "Spans",
"stored": "Tamanho dos dados"
},
"storage": {
"emptyTitle": "Ainda não há dados",
"bySignal": "Tamanho dos dados por sinal",
"splitNote": "Tamanho não comprimido em logs, métricas e traces",
"used": "Tamanho dos dados"
},
"limit": {
"used": "Ingerido hoje (UTC)",
"ofLimit": "de {limit}",
"daysOver": "Dias acima do limite este mês",
"approachingLimit": "A aproximar-se do limite",
"overLimit": "Acima do limite",
"ofUnlimited": "sem limite",
"unavailable": "Limite de ingestão indisponível",
"blockNote": "A pesquisa é bloqueada após mais de {allowed} dia acima do limite | A pesquisa é bloqueada após mais de {allowed} dias acima do limite",
"daysOverOf": "{days} de {allowed} permitidos",
"expiredLine": "Licença expirada",
"resets": "Reinicia às 00:00 UTC, daqui a {countdown}",
"subtitle": "O limite diário considera o dia UTC, não uma janela móvel de 24 h"
},
"empty": {
"incidents": "Sem incidentes em aberto",
"anomalies": "Nenhuma anomalia detetada no intervalo de tempo selecionado",
"anomalyNoChecks": "Não há verificações de anomalias configuradas",
"services": "Sem dados de serviços no intervalo de tempo selecionado",
"recentEvents": "Sem eventos de alerta no intervalo de tempo selecionado"
},
"stale": {
"message": "A mostrar dados de {time}. A atualização falhou.",
"retry": "Tentar novamente"
},
"dataAsOf": "Dados de {time}",
"status": {
"attention": "Requer atenção",
"checking": "A verificar…",
"partial": "Não foi possível carregar algumas verificações",
"clear": "Nada requer atenção",
"items": {
"firing": "{count} alerta disparado | {count} alertas disparados",
"failing": "{count} regra com falha | {count} regras com falha",
"incidents": "{count} incidente em aberto | {count} incidentes em aberto",
"pipelines": "{count} pipeline com erros | {count} pipelines com erros",
"quiet": "{count} stream sem atividade | {count} streams sem atividade"
},
"unmonitored": "Ainda não há nada a ser monitorizado"
},
"timeRange": {
"heading": "No intervalo de tempo selecionado"
}
},
"chatHistory": {
"title": "Conversas",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Reiniciar {count} coletores?",
"restartMessage": "Cada coletor reinicia na próxima mensagem do servidor. A recolha de dados pára brevemente em cada anfitrião.",
"restartQueued": "Reinício solicitado para {count} coletor | Reinício solicitado para {count} coletores",
"restartOffline": "Este coletor está offline. Reinicie-o quando voltar a estar online.",
"restartUnsupported": "Este coletor não aceita comandos de reinício",
"systemSections": "Secções geridas pelo sistema",
"systemSectionsCaption": "Extensão OpAMP e telemetria do serviço, definidas pelo supervisor e pelo servidor",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Abre o editor de {config}. Publique uma versão corrigida e mude esta frota para ela; esta implementação fica então substituída.",
"confirmResume": "A mesma implementação continua a partir da fase {n}.",
"confirmRollback": "Inicia de imediato uma implementação de regresso à versão estável, {config} v{version}. Os coletores que já a têm não recebem nada.",
"confirmRollbackNoStable": "Esta frota não tem uma versão estável, pelo que a implementação termina e os seus coletores deixam de recolher até implementar novamente.",
"confirmRollbackNoStable": "Inicia de imediato uma implementação de regresso à versão estável.",
"confirmSkip": "Exclui {count} coletor com falhas e continua. Fica marcado como excluído e é incluído novamente na próxima implementação. | Exclui {count} coletores com falhas e continua. Ficam marcados como excluídos e são incluídos novamente na próxima implementação.",
"confirmed": "Confirmado",
"eventConverged": "Fase {n} convergiu",
@@ -29358,7 +29479,6 @@
"phaseN": "Fase {n}",
"phaseTimedOut": "Tempo limite excedido",
"phaseTimeoutNote": "Uma fase que não confirma nem rejeita em {minutes} min interrompe a implementação (definição da frota)",
"phaseTimeoutNoteSeconds": "Uma fase que não confirme nem rejeite dentro de {minutes} min {seconds} s interrompe a implementação (definição da frota)",
"phaseVerifying": "A verificar",
"phases": "Fases",
"planEmpty": "Nenhum coletor está online, pelo que a implementação aguarda até um se ligar",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Herdar ({value})",
"offlineHeartbeats": "Offline após heartbeats em falta",
"offlineHeartbeatsHelp": "Um coletor fica offline após este número de intervalos de heartbeat sem mensagem",
"offlineRatio": "Proporção offline (%)",
"offlineRatioHelp": "Interrompe a fase quando mais do que esta proporção dos seus coletores está offline",
"offlineRatio": "Evento de proporção offline (%)",
"offlineRatioHelp": "Regista um evento da frota quando mais do que esta proporção de coletores está offline",
"outOfRange": "Introduza um número de {min} a {max}",
"phaseTimeout": "Tempo limite da fase (segundos)",
"phaseTimeoutHelp": "Uma fase da implementação que não confirma nem rejeita neste tempo é interrompida",
+169 -49
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Нет в вашей лицензии",
"title": "{feature} не входит в вашу лицензию",
"description": "Ваш администратор может добавить её, обновив лицензию, или свяжитесь с отделом продаж, чтобы получить лицензию, включающую её.",
"thisFeature": "Эта функция",
"contactSales": "Связаться с отделом продаж",
"toastTitle": "Не входит в вашу лицензию",
"toastMessage": "Ваша лицензия не включает: {features}. Попросите администратора обновить лицензию.",
"toastMessageGeneric": "Ваша лицензия не включает эту функцию. Попросите администратора обновить лицензию.",
"bannerMessage": "Эта функция заблокирована. Для неё нужна функция {feature} в вашей лицензии.",
"bannerMessageGeneric": "Для этой страницы нужна функция, которой нет в вашей лицензии.",
"headerLocked": "Заблокировано: нужна {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Истории пока нет",
@@ -3420,22 +3407,6 @@
"contact_name": "Контактное лицо",
"contact_email": "Контактный email",
"environment_type": "Тип среды",
"features": "Функции",
"features_all_unlocked": "Ваша лицензия открывает все функции.",
"features_summary": "Ваша лицензия открывает {ratio}. {free} бесплатная функция всегда включена. | Ваша лицензия открывает {ratio}. {free} бесплатных функций всегда включены.",
"features_paid_ratio": "{unlocked} из {paid} платной функции | {unlocked} из {paid} платных функций",
"features_unlocked": "Открыто",
"features_missing": "Нет в вашей лицензии",
"features_request_access": "Запросить доступ",
"features_search": "Поиск по {count} функции | Поиск по всем {count} функциям",
"features_filter_all": "Все",
"features_filter_free": "Бесплатные",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Нет функций, соответствующих поиску",
"features_none": "В этой группе нет функций",
"features_free_heading": "Бесплатные, входят в каждую лицензию",
"features_new": "Новые после вашей лицензии",
"features_filter_new": "Новые",
"request_new_license": "Запросить новую лицензию",
"add_new_license_key": "Добавить новый ключ лицензии",
"refresh_license_limits": "Обновить лимиты лицензии",
@@ -9918,7 +9889,7 @@
"activeAnomalies": "Активные аномалии",
"activeIncidents": "Активные инциденты",
"services": "Сервисы",
"recentEvents": "Последние события",
"recentEvents": "Недавние события",
"investigate": "Расследовать →",
"viewAll": "Показать все",
"showingOf": "Показано {shown} из {total}",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Прокрутить сервисы влево",
"scrollRight": "Прокрутить сервисы вправо",
"untitledIncident": "Инцидент без названия",
"allClear": "Все системы в порядке",
"allClearDesc": "Нет активных аномалий, инцидентов или деградировавших сервисов в выбранном временном окне. Ваша инфраструктура выглядит здоровой.",
"allClearDescScoped": "Нет открытых инцидентов, аномалий или деградировавших сервисов в выбранном временном диапазоне.",
"allClearPartial": "Сообщать нечего",
"allClearPartialDesc": "Ничего не требует внимания. Мы не проверяли {areas}, так как ваша лицензия не включает эти функции.",
"areaAlerts": "Оповещения",
"areaAnomalies": "Обнаружение аномалий",
"areaIncidents": "Инциденты",
"areaServices": "Сервисы",
"notMonitoredTitle": "Пока ничего не отслеживается",
"notMonitoredDesc": "Ваши данные поступают, но для них не выполняется ни оповещение, ни проверка аномалий. Добавьте одно из них, и Обзор покажет всё, что требует внимания.",
"notMonitoredAlert": "Создать оповещение",
"notMonitoredAlertDesc": "Получайте уведомления, когда запрос пересекает пороговое значение",
"notMonitoredAnomaly": "Настроить обнаружение аномалий",
"notMonitoredAnomalyDesc": "Выявляйте необычный объём без задания порога",
"emptyActionAlerts": "Просмотр правил оповещений",
"emptyActionAlertsDesc": "Просмотр настроенных оповещений и порогов",
"emptyActionLogs": "Исследовать логи",
"emptyActionLogsDesc": "Поиск и фильтрация потоков логов",
"emptyActionTraces": "Исследовать трейсы",
"emptyActionTracesDesc": "Просматривайте распределенные трейсы и задержки сервисов",
"anomalyDetectedTitle": "Обнаружена аномалия — {name}",
"anomalyDetectedDescription": "Поток: {stream} · аномальных точек: {count}",
"secondsAgo": "{count} сек. назад",
"minutesAgo": "{count} мин. назад",
"hoursAgo": "{count} ч назад",
"daysAgo": "{count} дн. назад"
"daysAgo": "{count} дн. назад",
"cards": {
"firingAlerts": "Срабатывающие оповещения",
"failingRules": "Правила со сбоями",
"openIncidents": "Открытые инциденты",
"pipelineErrors": "Ошибки конвейера",
"pipelinesOf": "из {enabled} включённых",
"quietStreams": "Неактивные потоки",
"firingNone": "Нет срабатывающих",
"noAlertRules": "Нет правил оповещения",
"noEnabledRules": "Нет включённых правил",
"failingNone": "Нет сбоев",
"failingNote": "Последняя проверка завершилась ошибкой",
"incidentsNone": "Нет открытых",
"noPipelines": "Нет конвейеров",
"noStreams": "Нет потоков",
"quietNote": "Данные не поступали последние 7 дней",
"criticalUnit": "{count} на критическом уровне",
"deliveryFailures": "Сбои доставки оповещений",
"deliveryNote": "Сработавшие оповещения, которые не удалось доставить",
"oldestFiring": "Самое давнее срабатывание: {age}"
},
"state": {
"tileForbidden": "Нет доступа",
"tileLocked": "Заблокировано",
"tileError": "Не удалось загрузить",
"tileUnavailable": "Недоступно",
"sectionLocked": "Доступно с корпоративной лицензией",
"sectionForbidden": "Ваша роль не позволяет видеть эти данные. Запросите доступ у администратора организации."
},
"time": {
"minutes": "{minutes} мин",
"hoursMinutes": "{hours} ч {minutes} мин",
"daysHours": "{days} д {hours} ч"
},
"level": {
"critical": "Критический",
"warning": "Предупреждение",
"okFiring": "Срабатывает"
},
"sections": {
"alerts": "Оповещения",
"dataHealth": "Состояние данных",
"alertsAndIncidents": "Оповещения и инциденты",
"limitAndStorage": "Лимит приёма и хранилище",
"storage": "Размер данных",
"topStreams": "Топ потоков по размеру данных"
},
"alerts": {
"firingFor": "Срабатывает уже",
"showMore": "Показать ещё {count} срабатывающее оповещение | Показать ещё {count} срабатывающих оповещений",
"emptyTitle": "Правил оповещения пока нет",
"emptyDesc": "Здесь отображаются созданные вами правила: срабатывания по уровням и правила, которые не запускаются.",
"emptyAction": "Создать оповещение",
"criticalLevel": "Критический уровень",
"warningLevel": "Уровень предупреждения",
"rulesFailing": "Правила со сбоями",
"firingNow": "Срабатывают сейчас, сначала старые",
"noneFiring": "Сейчас нет срабатывающих оповещений",
"rulesActive": "{count} правило активно | Активных правил: {count}",
"rulesFailingToEvaluate": "Не удаётся вычислить: {count} | Не удаётся вычислить: {count}",
"subtitle": "{active} · {failing}",
"viewAll": "Показать все оповещения",
"rulesEvaluated": "Вычисляется {count} правило | Вычисляется правил: {count}"
},
"dataHealth": {
"viewPipelines": "Показать конвейеры",
"noPipelines": "Конвейеров пока нет.",
"pipelinesErrored": "Конвейеры с ошибками",
"pipelinesNote": "Включённые конвейеры с ошибкой",
"quiet": "Потоки без данных",
"quietNote": "Не было записи от часа до 7 дней",
"failing": "Правила оповещений с ошибкой вычисления",
"failingNote": "Слепые зоны, не срабатывают",
"delivery": "Ошибки доставки оповещений",
"deliveryNote": "Сработавшие оповещения без уведомления",
"schema": "Изменения схемы за 24 ч",
"schemaNote": "Новые версии схемы в ваших потоках",
"status": {
"needsAction": "Требует действий",
"warning": "Предупреждение",
"healthy": "В норме",
"notSetUp": "Не настроено",
"info": "Информация"
},
"compaction": "Ожидающие задачи компакции",
"compactionNote": "По всем доступным вам типам потоков",
"pipelinesActive": "Из {count} активного конвейера | Из {count} активных конвейеров",
"subtitle": "Данные поступают, не повреждены и доставляются?"
},
"streams": {
"emptyTitle": "Потоков пока нет",
"emptyDesc": "Потоки появятся здесь, когда начнут поступать данные.",
"startIngesting": "Начать приём данных",
"allStreams": "Все потоки",
"shareOfTotal": "{percent}% от общего объёма",
"shareUnderOne": "Менее 1% от общего объёма",
"subtitle": "Крупнейшие доступные вам потоки",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Открыть",
"openAria": {
"logs": "Открыть логи",
"metrics": "Открыть метрики",
"traces": "Открыть трейсы"
},
"logs": "Логи",
"metrics": "Метрики",
"traces": "Трейсы",
"streams": "Потоки",
"events": "События",
"samples": "Сэмплы",
"spans": "Спаны",
"stored": "Объём данных"
},
"storage": {
"emptyTitle": "Данных пока нет",
"bySignal": "Объём данных по сигналам",
"splitNote": "Несжатый объём по логам, метрикам и трейсам",
"used": "Размер данных"
},
"limit": {
"used": "Принято сегодня (UTC)",
"ofLimit": "из {limit}",
"daysOver": "Дней сверх лимита в этом месяце",
"approachingLimit": "Приближение к лимиту",
"overLimit": "Лимит превышен",
"ofUnlimited": "без ограничений",
"unavailable": "Лимит приема недоступен",
"blockNote": "Поиск блокируется при превышении лимита более чем на {allowed} день | Поиск блокируется при превышении лимита более чем на {allowed} дней",
"daysOverOf": "{days} из {allowed} допустимых",
"expiredLine": "Срок действия лицензии истёк",
"resets": "Сброс в 00:00 UTC, через {countdown}",
"subtitle": "Дневной лимит считается по суткам UTC, а не по скользящим 24 часам"
},
"empty": {
"incidents": "Нет открытых инцидентов",
"anomalies": "Аномалии в выбранном временном диапазоне не обнаружены",
"anomalyNoChecks": "Проверки аномалий не настроены",
"services": "Нет данных о сервисах в выбранном временном диапазоне",
"recentEvents": "Нет событий оповещений в выбранном временном диапазоне"
},
"stale": {
"message": "Показаны данные на {time}. Обновление не удалось.",
"retry": "Повторить"
},
"dataAsOf": "Данные на {time}",
"status": {
"attention": "Требует внимания",
"checking": "Проверка…",
"partial": "Некоторые проверки не удалось загрузить",
"clear": "Ничего не требует внимания",
"items": {
"firing": "{count} оповещение срабатывает | {count} оповещений срабатывает",
"failing": "{count} правило с ошибкой | {count} правил с ошибкой",
"incidents": "{count} открытый инцидент | {count} открытых инцидентов",
"pipelines": "{count} конвейер с ошибками | {count} конвейеров с ошибками",
"quiet": "{count} неактивный поток | {count} неактивных потоков"
},
"unmonitored": "Мониторинг пока не настроен"
},
"timeRange": {
"heading": "В выбранном временном диапазоне"
}
},
"chatHistory": {
"title": "Чаты",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Перезапустить коллекторы ({count})?",
"restartMessage": "Каждый коллектор перезапустится при следующем сообщении от сервера. Сбор данных на каждом хосте ненадолго приостановится.",
"restartQueued": "Запрошен перезапуск {count} коллектора | Запрошен перезапуск коллекторов: {count}",
"restartOffline": "Этот коллектор офлайн. Перезапустите его, когда он снова будет в сети.",
"restartUnsupported": "Этот коллектор не принимает команды перезапуска",
"systemSections": "Разделы, управляемые системой",
"systemSectionsCaption": "Расширение OpAMP и телеметрия сервиса, задаются supervisor и сервером",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Открывает редактор {config}. Опубликуйте исправленную версию и переведите на неё этот парк; тогда это развертывание будет заменено.",
"confirmResume": "То же развертывание продолжится с этапа {n}.",
"confirmRollback": "Немедленно запускает откат на стабильную версию, {config} v{version}. Коллекторы, уже работающие на ней, ничего не получат.",
"confirmRollbackNoStable": "В этом парке нет стабильной версии, поэтому раскатка завершится, а её коллекторы перестанут собирать данные, пока вы снова не выполните развёртывание.",
"confirmRollbackNoStable": "Немедленно запускает откат на стабильную версию.",
"confirmSkip": "Исключает {count} сбойный коллектор и продолжает. Он помечается как исключённый и снова включается в следующее развертывание. | Исключает сбойные коллекторы ({count}) и продолжает. Они помечаются как исключённые и снова включаются в следующее развертывание.",
"confirmed": "Подтверждено",
"eventConverged": "Этап {n} завершён",
@@ -29358,7 +29479,6 @@
"phaseN": "Этап {n}",
"phaseTimedOut": "Время истекло",
"phaseTimeoutNote": "Этап, который не подтверждается и не отклоняется за {minutes} мин, останавливает развертывание (настройка парка)",
"phaseTimeoutNoteSeconds": "Фаза, которая не подтверждается и не отклоняется в течение {minutes} мин {seconds} с, останавливает раскатку (настройка парка)",
"phaseVerifying": "Проверка",
"phases": "Этапы",
"planEmpty": "Ни один коллектор не в сети, поэтому развёртывание ждёт, пока не подключится хотя бы один.",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Наследовать ({value})",
"offlineHeartbeats": "Не в сети после пропущенных heartbeat",
"offlineHeartbeatsHelp": "Коллектор считается не в сети, если сообщений нет столько интервалов heartbeat",
"offlineRatio": "Доля не в сети (%)",
"offlineRatioHelp": "Останавливает фазу, если больше этой доли её коллекторов не в сети",
"offlineRatio": "Событие доли «не в сети» (%)",
"offlineRatioHelp": "Записывает событие парка, когда не в сети более этой доли коллекторов",
"outOfRange": "Введите число от {min} до {max}",
"phaseTimeout": "Тайм-аут этапа (секунды)",
"phaseTimeoutHelp": "Этап развертывания, который не подтверждается и не отклоняется за это время, останавливается",
+172 -52
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "ไม่มีในสิทธิ์การใช้งานของคุณ",
"title": "{feature} ไม่ได้รวมอยู่ในสิทธิ์การใช้งานของคุณ",
"description": "ผู้ดูแลระบบของคุณสามารถเพิ่มได้โดยอัปเดตสิทธิ์การใช้งาน หรือติดต่อฝ่ายขายเพื่อขอสิทธิ์การใช้งานที่รวมฟีเจอร์นี้",
"thisFeature": "ฟีเจอร์นี้",
"contactSales": "ติดต่อฝ่ายขาย",
"toastTitle": "ไม่รวมอยู่ในสิทธิ์การใช้งานของคุณ",
"toastMessage": "สิทธิ์การใช้งานของคุณไม่รวม: {features} โปรดขอให้ผู้ดูแลระบบอัปเดตสิทธิ์การใช้งาน",
"toastMessageGeneric": "สิทธิ์การใช้งานของคุณไม่รวมฟีเจอร์นี้ โปรดขอให้ผู้ดูแลระบบอัปเดตสิทธิ์การใช้งาน",
"bannerMessage": "ฟีเจอร์นี้ถูกล็อก ต้องมีฟีเจอร์ {feature} ในสิทธิ์การใช้งานของคุณ",
"bannerMessageGeneric": "หน้านี้ต้องใช้ฟีเจอร์ที่ไม่มีในสิทธิ์การใช้งานของคุณ",
"headerLocked": "ล็อกอยู่: ต้องมี {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "No history yet",
@@ -3420,22 +3407,6 @@
"contact_name": "Contact Name",
"contact_email": "Contact Email",
"environment_type": "Environment Type",
"features": "ฟีเจอร์",
"features_all_unlocked": "สิทธิ์การใช้งานของคุณปลดล็อกฟีเจอร์ทั้งหมด",
"features_summary": "สิทธิ์การใช้งานของคุณปลดล็อก {ratio} ฟีเจอร์ฟรี {free} รายการถูกรวมไว้เสมอ | สิทธิ์การใช้งานของคุณปลดล็อก {ratio} ฟีเจอร์ฟรี {free} รายการถูกรวมไว้เสมอ",
"features_paid_ratio": "{unlocked} จาก {paid} ฟีเจอร์แบบชำระเงิน | {unlocked} จาก {paid} ฟีเจอร์แบบชำระเงิน",
"features_unlocked": "ปลดล็อกแล้ว",
"features_missing": "ไม่มีในสิทธิ์การใช้งานของคุณ",
"features_request_access": "ขอสิทธิ์การเข้าถึง",
"features_search": "ค้นหา {count} ฟีเจอร์ | ค้นหาฟีเจอร์ทั้งหมด {count} รายการ",
"features_filter_all": "ทั้งหมด",
"features_filter_free": "ฟรี",
"features_group_heading": "{heading} · {count}",
"features_no_match": "ไม่มีฟีเจอร์ที่ตรงกับการค้นหาของคุณ",
"features_none": "ไม่มีฟีเจอร์ในกลุ่มนี้",
"features_free_heading": "ฟรี รวมอยู่ในทุกสิทธิ์การใช้งาน",
"features_new": "ใหม่หลังจากสิทธิ์การใช้งานของคุณ",
"features_filter_new": "ใหม่",
"request_new_license": "Request new License",
"add_new_license_key": "Add New License Key",
"refresh_license_limits": "Refresh License Limits",
@@ -9913,10 +9884,10 @@
"alertsSuffix": "alerts",
"viewingLabel": "— viewing",
"timesSymbol": "×",
"activeAnomalies": "Active Anomalies",
"activeIncidents": "Active Incidents",
"activeAnomalies": "ความผิดปกติที่กำลังเกิดขึ้น",
"activeIncidents": "เหตุการณ์ขัดข้องที่กำลังดำเนินอยู่",
"services": "Services",
"recentEvents": "Recent Events",
"recentEvents": "เหตุการณ์ล่าสุด",
"investigate": "Investigate →",
"viewAll": "View all",
"showingOf": "Showing {shown} of {total}",
@@ -9925,34 +9896,185 @@
"colReqs": "Reqs",
"scrollLeft": "Scroll services left",
"scrollRight": "Scroll services right",
"untitledIncident": "Untitled Incident",
"allClear": "All systems clear",
"allClearDesc": "No active anomalies, incidents, or degraded services in the selected time window. Your infrastructure looks healthy.",
"allClearDescScoped": "No open incidents, anomalies or degraded services in the selected time range.",
"allClearPartial": "ไม่มีอะไรต้องรายงาน",
"allClearPartialDesc": "ไม่มีอะไรต้องดูแล เราไม่ได้ตรวจสอบ{areas} เนื่องจากไลเซนส์ของคุณไม่รวมฟีเจอร์เหล่านั้น",
"areaAlerts": "การแจ้งเตือน",
"areaAnomalies": "การตรวจจับความผิดปกติ",
"areaIncidents": "เหตุการณ์ขัดข้อง",
"areaServices": "บริการ",
"untitledIncident": "เหตุการณ์ขัดข้องที่ไม่มีชื่อ",
"notMonitoredTitle": "Nothing is watched yet",
"notMonitoredDesc": "Your data is arriving, but no alert or anomaly check runs on it. Add one and Overview lists anything that needs attention.",
"notMonitoredAlert": "Create an alert",
"notMonitoredAlertDesc": "Get notified when a query crosses a threshold",
"notMonitoredAnomaly": "Set up anomaly detection",
"notMonitoredAnomalyDesc": "Flag unusual volume without picking a threshold",
"emptyActionAlerts": "View alert rules",
"emptyActionAlertsDesc": "Review configured alerts and thresholds",
"emptyActionLogs": "Explore logs",
"emptyActionLogsDesc": "Search and filter your log streams",
"emptyActionTraces": "Explore traces",
"emptyActionTracesDesc": "Inspect distributed traces and service latency",
"anomalyDetectedTitle": "Anomaly detected — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} anomalous point(s)",
"secondsAgo": "{count}s ago",
"minutesAgo": "{count}m ago",
"hoursAgo": "{count}h ago",
"daysAgo": "{count}d ago"
"daysAgo": "{count}d ago",
"cards": {
"firingAlerts": "การแจ้งเตือนที่กำลังแจ้งเตือน",
"failingRules": "กฎที่ล้มเหลว",
"openIncidents": "เหตุการณ์ขัดข้องที่เปิดอยู่",
"pipelineErrors": "ข้อผิดพลาดของไปป์ไลน์",
"pipelinesOf": "จาก {enabled} ที่เปิดใช้งาน",
"quietStreams": "สตรีมที่ไม่มีการเขียนข้อมูล",
"firingNone": "ไม่มีรายการที่กำลังแจ้งเตือน",
"noAlertRules": "ไม่มีกฎการแจ้งเตือน",
"noEnabledRules": "ไม่มีกฎที่เปิดใช้งาน",
"failingNone": "ไม่มีรายการที่ล้มเหลว",
"failingNote": "การประเมินครั้งล่าสุดเกิดข้อผิดพลาด",
"incidentsNone": "ไม่มีรายการที่เปิดอยู่",
"noPipelines": "ไม่มีไปป์ไลน์",
"noStreams": "ไม่มีสตรีม",
"quietNote": "หยุดเขียนข้อมูลใน 7 วันที่ผ่านมา",
"criticalUnit": "{count} รายการที่ระดับวิกฤต",
"deliveryFailures": "ความล้มเหลวในการส่งการแจ้งเตือน",
"deliveryNote": "การแจ้งเตือนที่ถูกทริกเกอร์แล้วแต่ไม่สามารถส่งการแจ้งเตือนได้",
"oldestFiring": "รายการที่กำลังแจ้งเตือนนานที่สุดเป็นเวลา {age}"
},
"state": {
"tileForbidden": "ไม่มีสิทธิ์เข้าถึง",
"tileLocked": "ถูกล็อก",
"tileError": "ไม่สามารถโหลดได้",
"tileUnavailable": "ไม่พร้อมใช้งาน",
"sectionLocked": "ใช้งานได้ด้วยใบอนุญาตระดับองค์กร",
"sectionForbidden": "บทบาทของคุณไม่สามารถดูข้อมูลนี้ได้ โปรดขอสิทธิ์เข้าถึงจากผู้ดูแลองค์กร"
},
"time": {
"minutes": "{minutes} นาที",
"hoursMinutes": "{hours} ชม. {minutes} นาที",
"daysHours": "{days} วัน {hours} ชม."
},
"level": {
"critical": "วิกฤต",
"warning": "คำเตือน",
"okFiring": "กำลังแจ้งเตือน"
},
"sections": {
"alerts": "การแจ้งเตือน",
"dataHealth": "สุขภาพข้อมูล",
"alertsAndIncidents": "การแจ้งเตือนและเหตุการณ์ขัดข้อง",
"limitAndStorage": "ขีดจำกัดการนำเข้าข้อมูลและพื้นที่จัดเก็บ",
"storage": "ขนาดข้อมูล",
"topStreams": "สตรีมอันดับสูงสุดตามขนาดข้อมูล"
},
"alerts": {
"firingFor": "กำลังแจ้งเตือนเป็นเวลา",
"showMore": "แสดงการแจ้งเตือนที่กำลังแจ้งเตือนเพิ่มอีก {count} รายการ | แสดงการแจ้งเตือนที่กำลังแจ้งเตือนเพิ่มอีก {count} รายการ",
"emptyTitle": "ยังไม่มีกฎการแจ้งเตือน",
"emptyDesc": "กฎที่คุณสร้างจะแสดงที่นี่ พร้อมรายการที่กำลังแจ้งเตือนตามระดับ และรายการที่ล้มเหลวในการทำงาน",
"emptyAction": "สร้างการแจ้งเตือน",
"criticalLevel": "ระดับวิกฤต",
"warningLevel": "ระดับคำเตือน",
"rulesFailing": "กฎที่กำลังล้มเหลว",
"firingNow": "กำลังแจ้งเตือนอยู่ เรียงจากเก่าสุดก่อน",
"noneFiring": "ขณะนี้ไม่มีการแจ้งเตือนที่กำลังแจ้งเตือน",
"rulesActive": "{count} กฎที่ใช้งานอยู่ | {count} กฎที่ใช้งานอยู่",
"rulesFailingToEvaluate": "{count} กฎที่ประเมินไม่สำเร็จ | {count} กฎที่ประเมินไม่สำเร็จ",
"subtitle": "{active} · {failing}",
"viewAll": "ดูการแจ้งเตือนทั้งหมด",
"rulesEvaluated": "{count} กฎกำลังได้รับการประเมิน | {count} กฎกำลังได้รับการประเมิน"
},
"dataHealth": {
"viewPipelines": "ดูไปป์ไลน์",
"noPipelines": "ยังไม่มีไปป์ไลน์",
"pipelinesErrored": "ไปป์ไลน์ที่มีข้อผิดพลาด",
"pipelinesNote": "ไปป์ไลน์ที่เปิดใช้งานซึ่งมีข้อผิดพลาด",
"quiet": "สตรีมที่ไม่มีข้อมูลเข้ามา",
"quietNote": "หยุดเขียนข้อมูลเมื่อ 1 ชั่วโมงถึง 7 วันที่ผ่านมา",
"failing": "กฎการแจ้งเตือนที่ประเมินไม่สำเร็จ",
"failingNote": "จุดบอดที่ไม่ได้แจ้งเตือน",
"delivery": "การส่งการแจ้งเตือนล้มเหลว",
"deliveryNote": "การแจ้งเตือนที่ถูกทริกเกอร์แต่ส่งการแจ้งเตือนไม่ได้",
"schema": "การเปลี่ยนแปลงสคีมาใน 24 ชม.",
"schemaNote": "สคีมาเวอร์ชันใหม่ในสตรีมของคุณ",
"status": {
"needsAction": "ต้องดำเนินการ",
"warning": "คำเตือน",
"healthy": "ปกติ",
"notSetUp": "ยังไม่ได้ตั้งค่า",
"info": "ข้อมูล"
},
"compaction": "งานประมวลผลการบีบอัดที่รอดำเนินการ",
"compactionNote": "ในทุกประเภทสตรีมที่คุณมองเห็น",
"pipelinesActive": "จาก {count} ไปป์ไลน์ที่ใช้งานอยู่ | จาก {count} ไปป์ไลน์ที่ใช้งานอยู่",
"subtitle": "ข้อมูลมาถึง ครบถ้วน และถูกส่งต่อหรือไม่?"
},
"streams": {
"emptyTitle": "ยังไม่มีสตรีม",
"emptyDesc": "สตรีมจะแสดงที่นี่เมื่อมีข้อมูลเริ่มเข้ามา",
"startIngesting": "เริ่มนำเข้าข้อมูล",
"allStreams": "สตรีมทั้งหมด",
"shareOfTotal": "{percent}% ของทั้งหมด",
"shareUnderOne": "ต่ำกว่า 1% ของทั้งหมด",
"subtitle": "สตรีมที่ใหญ่ที่สุดที่คุณเข้าถึงได้",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "เปิด",
"openAria": {
"logs": "เปิดล็อก",
"metrics": "เปิดเมทริกซ์",
"traces": "เปิดเทรซ"
},
"logs": "ล็อก",
"metrics": "เมทริกซ์",
"traces": "เทรซ",
"streams": "สตรีม",
"events": "เหตุการณ์",
"samples": "ตัวอย่าง",
"spans": "สแปน",
"stored": "ขนาดข้อมูล"
},
"storage": {
"emptyTitle": "ยังไม่มีข้อมูล",
"bySignal": "ขนาดข้อมูลตามสัญญาณ",
"splitNote": "ขนาดข้อมูลที่ยังไม่บีบอัดในล็อก เมทริกซ์ และเทรซ",
"used": "ขนาดข้อมูล"
},
"limit": {
"used": "นำเข้าข้อมูลวันนี้ (UTC)",
"ofLimit": "จาก {limit}",
"daysOver": "จำนวนวันที่เกินขีดจำกัดในเดือนนี้",
"approachingLimit": "ใกล้ถึงขีดจำกัด",
"overLimit": "เกินขีดจำกัด",
"ofUnlimited": "จากแบบไม่จำกัด",
"unavailable": "ไม่สามารถดูขีดจำกัดการนำเข้าข้อมูลได้",
"blockNote": "การค้นหาจะถูกบล็อกเมื่อเกินขีดจำกัดมากกว่า {allowed} วัน | การค้นหาจะถูกบล็อกเมื่อเกินขีดจำกัดมากกว่า {allowed} วัน",
"daysOverOf": "{days} จาก {allowed} ที่อนุญาต",
"expiredLine": "ไลเซนส์หมดอายุ",
"resets": "รีเซ็ตเวลา 00:00 UTC อีก {countdown}",
"subtitle": "ขีดจำกัดรายวันนับตามวัน UTC ไม่ใช่ช่วง 24 ชั่วโมงแบบเลื่อน"
},
"empty": {
"incidents": "ไม่มีเหตุการณ์ขัดข้องที่เปิดอยู่",
"anomalies": "ไม่พบความผิดปกติในช่วงเวลาที่เลือก",
"anomalyNoChecks": "ยังไม่มีการตั้งค่าการตรวจสอบความผิดปกติ",
"services": "ไม่มีข้อมูลบริการในช่วงเวลาที่เลือก",
"recentEvents": "ไม่มีเหตุการณ์การแจ้งเตือนในช่วงเวลาที่เลือก"
},
"stale": {
"message": "กำลังแสดงข้อมูลจาก {time} การรีเฟรชล้มเหลว",
"retry": "ลองอีกครั้ง"
},
"dataAsOf": "ข้อมูล ณ {time}",
"status": {
"attention": "ต้องได้รับการดูแล",
"checking": "กำลังตรวจสอบ…",
"partial": "ไม่สามารถโหลดการตรวจสอบบางรายการได้",
"clear": "ไม่มีสิ่งที่ต้องได้รับการดูแล",
"items": {
"firing": "{count} การแจ้งเตือนกำลังแจ้งเตือน | {count} การแจ้งเตือนกำลังแจ้งเตือน",
"failing": "{count} กฎที่ทำงานผิดพลาด | {count} กฎที่ทำงานผิดพลาด",
"incidents": "{count} เหตุการณ์ขัดข้องที่เปิดอยู่ | {count} เหตุการณ์ขัดข้องที่เปิดอยู่",
"pipelines": "{count} ไปป์ไลน์ที่มีข้อผิดพลาด | {count} ไปป์ไลน์ที่มีข้อผิดพลาด",
"quiet": "{count} สตรีมที่ไม่มีความเคลื่อนไหว | {count} สตรีมที่ไม่มีความเคลื่อนไหว"
},
"unmonitored": "ยังไม่มีการตรวจสอบใด ๆ"
},
"timeRange": {
"heading": "ในช่วงเวลาที่เลือก"
}
},
"chatHistory": {
"title": "Chats",
@@ -29190,7 +29312,6 @@
"restartManyTitle": "Restart {count} collectors?",
"restartMessage": "Each collector restarts on its next message from the server. Data collection pauses briefly on each host.",
"restartQueued": "Restart requested for {count} collector | Restart requested for {count} collectors",
"restartOffline": "คอลเลกเตอร์นี้อยู่ในสถานะออฟไลน์ ให้รีสตาร์ตเมื่อกลับมาออนไลน์อีกครั้ง",
"restartUnsupported": "This collector does not accept restart commands",
"systemSections": "System-managed sections",
"systemSectionsCaption": "OpAMP extension and service telemetry, set by the supervisor and the server",
@@ -29319,7 +29440,7 @@
"confirmFixForward": "Opens the {config} editor. Publish a fixed version and change this fleet to it; this rollout is then superseded.",
"confirmResume": "The same rollout continues from phase {n}.",
"confirmRollback": "Starts an immediate rollout back to the stable version, {config} v{version}. Collectors already on it receive nothing.",
"confirmRollbackNoStable": "กลุ่มเครื่องนี้ไม่มีเวอร์ชันที่เสถียร การทยอยปรับใช้จึงสิ้นสุดลง และคอลเลกเตอร์จะหยุดเก็บข้อมูลจนกว่าคุณจะปรับใช้ใหม่",
"confirmRollbackNoStable": "Starts an immediate rollout back to the stable version.",
"confirmSkip": "Excludes {count} failing collector and continues. It is marked excluded and included again in the next rollout. | Excludes {count} failing collectors and continues. They are marked excluded and included again in the next rollout.",
"confirmed": "Confirmed",
"eventConverged": "Phase {n} converged",
@@ -29356,7 +29477,6 @@
"phaseN": "Phase {n}",
"phaseTimedOut": "Timed out",
"phaseTimeoutNote": "A phase that neither confirms nor rejects within {minutes} min halts the rollout (fleet setting)",
"phaseTimeoutNoteSeconds": "เฟสที่ไม่ยืนยันหรือปฏิเสธภายใน {minutes} นาที {seconds} วินาที จะทำให้การทยอยปรับใช้หยุดลง (การตั้งค่ากลุ่มเครื่อง)",
"phaseVerifying": "Verifying",
"phases": "Phases",
"planEmpty": "ไม่มีคอลเลกเตอร์ออนไลน์ ดังนั้นการทยอยติดตั้งจะรอจนกว่าจะมีตัวหนึ่งเชื่อมต่อ",
@@ -29497,8 +29617,8 @@
"inheritPlaceholder": "Inherit ({value})",
"offlineHeartbeats": "Offline after missed heartbeats",
"offlineHeartbeatsHelp": "A collector is offline after this many heartbeat intervals without a message",
"offlineRatio": "สัดส่วนออฟไลน์ (%)",
"offlineRatioHelp": "หยุดเฟสเมื่อมีคอลเลกเตอร์ออฟไลน์เกินสัดส่วนนี้",
"offlineRatio": "Offline-ratio event (%)",
"offlineRatioHelp": "Records a fleet event when more than this share of collectors is offline",
"outOfRange": "Enter a number from {min} to {max}",
"phaseTimeout": "Phase timeout (seconds)",
"phaseTimeoutHelp": "A rollout phase that neither confirms nor rejects in this time halts",
+172 -52
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Lisansınızda yok",
"title": "{feature} lisansınıza dahil değil",
"description": "Yöneticiniz lisansı güncelleyerek bunu ekleyebilir veya bunu içeren bir lisans almak için satış ekibiyle iletişime geçebilir.",
"thisFeature": "Bu özellik",
"contactSales": "Satış ekibiyle iletişime geçin",
"toastTitle": "Lisansınıza dahil değil",
"toastMessage": "Lisansınız şunları içermiyor: {features}. Yöneticinizden lisansı güncellemesini isteyin.",
"toastMessageGeneric": "Lisansınız bu özelliği içermiyor. Yöneticinizden lisansı güncellemesini isteyin.",
"bannerMessage": "Bu özellik kilitli. Lisansınızda {feature} özelliği gerekiyor.",
"bannerMessageGeneric": "Bu sayfa lisansınızda olmayan bir özellik gerektiriyor.",
"headerLocked": "Kilitli: {feature} gerekiyor"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Henüz geçmiş yok",
@@ -3420,22 +3407,6 @@
"contact_name": "İletişim Adı",
"contact_email": "İletişim E-posta",
"environment_type": "Ortam Türü",
"features": "Özellikler",
"features_all_unlocked": "Lisansınız tüm özelliklerin kilidini açar.",
"features_summary": "Lisansınız {ratio} kısmının kilidini açar. {free} ücretsiz özellik her zaman dahildir. | Lisansınız {ratio} kısmının kilidini açar. {free} ücretsiz özellik her zaman dahildir.",
"features_paid_ratio": "{paid} ücretli özellikten {unlocked} tanesi | {paid} ücretli özellikten {unlocked} tanesi",
"features_unlocked": "Kilidi açık",
"features_missing": "Lisansınızda yok",
"features_request_access": "Erişim Talep Et",
"features_search": "{count} özellikte ara | Tüm {count} özellikte ara",
"features_filter_all": "Tümü",
"features_filter_free": "Ücretsiz",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Aramanızla eşleşen özellik yok",
"features_none": "Bu grupta özellik yok",
"features_free_heading": "Ücretsiz, her lisansa dahildir",
"features_new": "Lisansınızdan sonra eklenen",
"features_filter_new": "Yeni",
"request_new_license": "Yeni Lisans isteyin",
"add_new_license_key": "Yeni Lisans Anahtarı Ekle",
"refresh_license_limits": "Lisans Sınırlarını Yenile",
@@ -9915,10 +9886,10 @@
"alertsSuffix": "uyarılar",
"viewingLabel": "— görüntüleniyor",
"timesSymbol": "×",
"activeAnomalies": "Aktif Anomaliler",
"activeIncidents": "Aktif Vakalar",
"activeAnomalies": "Aktif anomaliler",
"activeIncidents": "Aktif vakalar",
"services": "HİZMETLER",
"recentEvents": "Son Olaylar",
"recentEvents": "Son olaylar",
"investigate": "Araştırın→",
"viewAll": "Tümünü görüntüle",
"showingOf": "{total} arasından {shown} gösteriliyor",
@@ -9927,34 +9898,185 @@
"colReqs": "İstek",
"scrollLeft": "Hizmetleri sola kaydır",
"scrollRight": "Hizmetleri sağa kaydır",
"untitledIncident": "İsimsiz Vaka",
"allClear": "Hepsi temiz",
"allClearDesc": "Seçilen zaman aralığında etkin anomali, vaka veya performansı düşmüş hizmet yok. Altyapınız sağlıklı görünüyor.",
"allClearDescScoped": "Seçilen zaman aralığında açık vaka, anomali veya performansı düşmüş hizmet yok.",
"allClearPartial": "Rapor edilecek bir şey yok",
"allClearPartialDesc": "İlgi gerektiren bir durum yok. Lisansınız bu özellikleri içermediği için {areas} kontrol edilmedi.",
"areaAlerts": "Uyarılar",
"areaAnomalies": "Anomali tespiti",
"areaIncidents": "Vakalar",
"areaServices": "Hizmetler",
"untitledIncident": "Başlıksız vaka",
"notMonitoredTitle": "Henüz hiçbir şey izlenmiyor",
"notMonitoredDesc": "Verileriniz geliyor, ancak üzerinde hiçbir uyarı veya anomali kontrolü çalışmıyor. Bir tane ekleyin; Genel Bakış dikkat gerektiren her şeyi listeler.",
"notMonitoredAlert": "Uyarı oluştur",
"notMonitoredAlertDesc": "Bir sorgu eşiği aştığında bildirim alın",
"notMonitoredAnomaly": "Anomali algılamayı ayarla",
"notMonitoredAnomalyDesc": "Eşik belirlemeden olağan dışı hacmi işaretle",
"emptyActionAlerts": "Uyarı kurallarını görüntüle",
"emptyActionAlertsDesc": "Yapılandırılmış uyarıları ve eşikleri gözden geçirin",
"emptyActionLogs": "Günlükleri keşfedin",
"emptyActionLogsDesc": "Günlük akışlarınızı arayın ve filtreleyin",
"emptyActionTraces": "İzleri Keşfet",
"emptyActionTracesDesc": "Dağıtık izleri ve hizmet gecikmesini inceleyin",
"anomalyDetectedTitle": "Anomali tespit edildi — {name}",
"anomalyDetectedDescription": "Akış: {stream} · {count} anormal nokta",
"secondsAgo": "{count} sn önce",
"minutesAgo": "{count} dk önce",
"hoursAgo": "{count} sa önce",
"daysAgo": "{count} gün önce"
"daysAgo": "{count} gün önce",
"cards": {
"firingAlerts": "Tetiklenmiş uyarılar",
"failingRules": "Hata veren kurallar",
"openIncidents": "Açık vakalar",
"pipelineErrors": "İşlem hattı hataları",
"pipelinesOf": "/ {enabled} etkin",
"quietStreams": "Sessiz akışlar",
"firingNone": "Tetiklenen yok",
"noAlertRules": "Uyarı kuralı yok",
"noEnabledRules": "Etkin kural yok",
"failingNone": "Hata veren yok",
"failingNote": "Son değerlendirme hata verdi",
"incidentsNone": "Açık olan yok",
"noPipelines": "İşlem hattı yok",
"noStreams": "Akış yok",
"quietNote": "Son 7 gündür yazmıyor",
"criticalUnit": "{count} tanesi kritik seviyede",
"deliveryFailures": "Uyarı gönderim hataları",
"deliveryNote": "Tetiklenen ancak bildirimi iletilemeyen uyarılar",
"oldestFiring": "En eski tetiklenme: {age}"
},
"state": {
"tileForbidden": "Erişim yok",
"tileLocked": "Kilitli",
"tileError": "Yüklenemedi",
"tileUnavailable": "Kullanılamıyor",
"sectionLocked": "Kurumsal lisansla kullanılabilir",
"sectionForbidden": "Rolünüz bu verileri göremez. Erişim için bir kuruluş yöneticisinden izin isteyin."
},
"time": {
"minutes": "{minutes}dk",
"hoursMinutes": "{hours}sa {minutes}dk",
"daysHours": "{days}g {hours}sa"
},
"level": {
"critical": "Kritik",
"warning": "Uyarı",
"okFiring": "Tetiklenmiş"
},
"sections": {
"alerts": "Uyarılar",
"dataHealth": "Veri sağlığı",
"alertsAndIncidents": "Uyarılar ve vakalar",
"limitAndStorage": "Veri alımı limiti ve depolama",
"storage": "Veri boyutu",
"topStreams": "Veri boyutuna göre en büyük akışlar"
},
"alerts": {
"firingFor": "Tetiklenme süresi",
"showMore": "{count} tetiklenmiş uyarı daha göster | {count} tetiklenmiş uyarı daha göster",
"emptyTitle": "Henüz uyarı kuralı yok",
"emptyDesc": "Oluşturduğunuz kurallar, seviyeye göre tetiklenenler ve çalıştırılamayanlarla birlikte burada görünür.",
"emptyAction": "Uyarı oluştur",
"criticalLevel": "Kritik seviye",
"warningLevel": "Uyarı seviyesi",
"rulesFailing": "Hata veren kurallar",
"firingNow": "Şu anda tetiklenenler, en eski başta",
"noneFiring": "Şu anda tetiklenmiş uyarı yok",
"rulesActive": "{count} kural etkin | {count} kural etkin",
"rulesFailingToEvaluate": "{count} değerlendirilemiyor | {count} değerlendirilemiyor",
"subtitle": "{active} · {failing}",
"viewAll": "Tüm uyarıları görüntüle",
"rulesEvaluated": "{count} kural değerlendiriliyor | {count} kural değerlendiriliyor"
},
"dataHealth": {
"viewPipelines": "İşlem hatlarını görüntüle",
"noPipelines": "Henüz işlem hattı yok.",
"pipelinesErrored": "Hatalı işlem hatları",
"pipelinesNote": "Hata alan etkin işlem hatları",
"quiet": "Sessizleşen akışlar",
"quietNote": "Bir saat ile 7 gün önce arasında yazmayı durdurdu",
"failing": "Değerlendirilemeyen uyarı kuralları",
"failingNote": "Kör noktalar, tetiklenmiyor",
"delivery": "Uyarı iletim hataları",
"deliveryNote": "Tetiklenen ancak bildirim gönderemeyen uyarılar",
"schema": "24 saat içindeki şema değişiklikleri",
"schemaNote": "Akışlarınızdaki yeni şema sürümleri",
"status": {
"needsAction": "İşlem gerekli",
"warning": "Uyarı",
"healthy": "Sağlıklı",
"notSetUp": "Kurulmadı",
"info": "Bilgi"
},
"compaction": "Bekleyen sıkıştırma işleri",
"compactionNote": "Görüntüleyebildiğiniz akış türleri genelinde",
"pipelinesActive": "{count} etkin işlem hattından | {count} etkin işlem hattından",
"subtitle": "Veri ulaşıyor mu, bütünlüğü korunuyor ve teslim ediliyor mu?"
},
"streams": {
"emptyTitle": "Henüz akış yok",
"emptyDesc": "Veri gelmeye başladığında akışlar burada görünür.",
"startIngesting": "Veri alımını başlat",
"allStreams": "Tüm akışlar",
"shareOfTotal": "Toplamın %{percent}'i",
"shareUnderOne": "Toplamın %1'inin altında",
"subtitle": "Erişebildiğiniz en büyük akışlar",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Aç",
"openAria": {
"logs": "Günlükleri aç",
"metrics": "Metrikleri aç",
"traces": "İzleri aç"
},
"logs": "Günlükler",
"metrics": "Metrikler",
"traces": "İzler",
"streams": "Akışlar",
"events": "Olaylar",
"samples": "Örnekler",
"spans": "Span'lar",
"stored": "Veri boyutu"
},
"storage": {
"emptyTitle": "Henüz veri yok",
"bySignal": "Sinyale göre veri boyutu",
"splitNote": "Günlükler, metrikler ve izler genelinde sıkıştırılmamış boyut",
"used": "Veri boyutu"
},
"limit": {
"used": "Bugün alınan veri (UTC)",
"ofLimit": "/ {limit}",
"daysOver": "Bu ay limit aşımı olan günler",
"approachingLimit": "Limite yaklaşılıyor",
"overLimit": "Limit aşıldı",
"ofUnlimited": "/ sınırsız",
"unavailable": "Veri alım limiti kullanılamıyor",
"blockNote": "Limit {allowed} günden fazla aşılırsa arama engellenir | Limit {allowed} günden fazla aşılırsa arama engellenir",
"daysOverOf": "İzin verilen {allowed} günün {days} günü",
"expiredLine": "Lisans süresi doldu",
"resets": "00:00 UTC'de sıfırlanır, {countdown} sonra",
"subtitle": "Günlük limit kayan 24 saati değil, UTC gününü esas alır"
},
"empty": {
"incidents": "Açık vaka yok",
"anomalies": "Seçilen zaman aralığında anomali bulunamadı",
"anomalyNoChecks": "Anomali kontrolü ayarlanmamış",
"services": "Seçilen zaman aralığında hizmet verisi yok",
"recentEvents": "Seçilen zaman aralığında uyarı olayı yok"
},
"stale": {
"message": "Veriler {time} itibarıyla gösteriliyor. Yenileme başarısız oldu.",
"retry": "Yeniden dene"
},
"dataAsOf": "Veriler {time} itibarıyla",
"status": {
"attention": "Dikkat gerekiyor",
"checking": "Kontrol ediliyor…",
"partial": "Bazı kontroller yüklenemedi",
"clear": "Dikkat gereken bir durum yok",
"items": {
"firing": "{count} uyarı tetiklenmiş | {count} uyarı tetiklenmiş",
"failing": "{count} kural başarısız | {count} kural başarısız",
"incidents": "{count} açık vaka | {count} açık vaka",
"pipelines": "{count} hatalı işlem hattı | {count} hatalı işlem hattı",
"quiet": "{count} sessiz akış | {count} sessiz akış"
},
"unmonitored": "Henüz hiçbir şey izlenmiyor"
},
"timeRange": {
"heading": "Seçilen zaman aralığında"
}
},
"chatHistory": {
"title": "Sohbetler",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "{count} toplayıcı yeniden başlatılsın mı?",
"restartMessage": "Her toplayıcı, sunucudan gelen bir sonraki mesajda yeniden başlar. Veri toplama her ana bilgisayarda kısa süre duraklar.",
"restartQueued": "{count} toplayıcı için yeniden başlatma istendi | {count} toplayıcı için yeniden başlatma istendi",
"restartOffline": "Bu toplayıcı çevrimdışı. Geri geldiğinde yeniden başlatın.",
"restartUnsupported": "Bu toplayıcı yeniden başlatma komutlarını kabul etmiyor",
"systemSections": "Sistem tarafından yönetilen bölümler",
"systemSectionsCaption": "OpAMP uzantısı ve hizmet telemetrisi; supervisor ve sunucu tarafından ayarlanır",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "{config} düzenleyicisini açar. Düzeltilmiş bir sürüm yayınlayın ve bu filoyu ona geçirin; bu dağıtımın yerini o alır.",
"confirmResume": "Aynı dağıtım {n}. aşamadan devam eder.",
"confirmRollback": "Kararlı sürüme, {config} v{version}, hemen geri dönen bir dağıtım başlatır. Zaten bu sürümde olan toplayıcılara hiçbir şey gönderilmez.",
"confirmRollbackNoStable": "Bu filonun kararlı bir sürümü yok; bu nedenle dağıtım sona erer ve siz yeniden dağıtım yapana kadar toplayıcıları veri toplamayı durdurur.",
"confirmRollbackNoStable": "Kararlı sürüme hemen geri dönen bir dağıtım başlatır.",
"confirmSkip": "Başarısız olan {count} toplayıcıyı hariç tutar ve devam eder. Hariç tutuldu olarak işaretlenir ve bir sonraki dağıtıma yeniden dahil edilir. | Başarısız olan {count} toplayıcıyı hariç tutar ve devam eder. Hariç tutuldu olarak işaretlenirler ve bir sonraki dağıtıma yeniden dahil edilirler.",
"confirmed": "Onaylandı",
"eventConverged": "{n}. aşama yakınsadı",
@@ -29358,7 +29479,6 @@
"phaseN": "{n}. aşama",
"phaseTimedOut": "Zaman aşımı",
"phaseTimeoutNote": "{minutes} dk içinde ne onaylanan ne de reddedilen bir aşama dağıtımı durdurur (filo ayarı)",
"phaseTimeoutNoteSeconds": "{minutes} dk {seconds} sn içinde ne onaylanan ne de reddedilen bir aşama dağıtımı durdurur (filo ayarı)",
"phaseVerifying": "Doğrulanıyor",
"phases": "Aşamalar",
"planEmpty": "Hiçbir toplayıcı çevrimiçi değil, bu nedenle dağıtım bir toplayıcı bağlanana kadar bekler",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Devral ({value})",
"offlineHeartbeats": "Kaçırılan kalp atışından sonra çevrimdışı",
"offlineHeartbeatsHelp": "Bir toplayıcı, bu kadar kalp atışı aralığı boyunca mesaj göndermezse çevrimdışı sayılır",
"offlineRatio": "Çevrimdışı oranı (%)",
"offlineRatioHelp": "Toplayıcılarının bu orandan daha fazlası çevrimdışı olduğunda aşamayı durdurur",
"offlineRatio": "Çevrimdışı oranı olayı (%)",
"offlineRatioHelp": "Toplayıcıların bu orandan fazlası çevrimdışı olduğunda bir filo olayı kaydeder",
"outOfRange": "{min} ile {max} arasında bir sayı girin",
"phaseTimeout": "Aşama zaman aşımı (saniye)",
"phaseTimeoutHelp": "Bu süre içinde ne onaylanan ne de reddedilen bir dağıtım aşaması durdurulur",
+168 -48
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "Không có trong giấy phép của bạn",
"title": "{feature} không có trong giấy phép của bạn",
"description": "Quản trị viên của bạn có thể thêm bằng cách cập nhật giấy phép, hoặc liên hệ bộ phận kinh doanh để nhận giấy phép có bao gồm tính năng này.",
"thisFeature": "Tính năng này",
"contactSales": "Liên hệ kinh doanh",
"toastTitle": "Không có trong giấy phép của bạn",
"toastMessage": "Giấy phép của bạn không bao gồm: {features}. Hãy yêu cầu quản trị viên cập nhật giấy phép.",
"toastMessageGeneric": "Giấy phép của bạn không bao gồm tính năng này. Hãy yêu cầu quản trị viên cập nhật giấy phép.",
"bannerMessage": "Tính năng này bị khóa. Cần có tính năng {feature} trong giấy phép của bạn.",
"bannerMessageGeneric": "Trang này cần một tính năng không có trong giấy phép của bạn.",
"headerLocked": "Đã khóa: cần {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "Chưa có lịch sử",
@@ -3420,22 +3407,6 @@
"contact_name": "Tên liên hệ",
"contact_email": "Email liên hệ",
"environment_type": "Loại môi trường",
"features": "Tính năng",
"features_all_unlocked": "Giấy phép của bạn mở khóa tất cả tính năng.",
"features_summary": "Giấy phép của bạn mở khóa {ratio}. {free} tính năng miễn phí luôn được bao gồm. | Giấy phép của bạn mở khóa {ratio}. {free} tính năng miễn phí luôn được bao gồm.",
"features_paid_ratio": "{unlocked} trên {paid} tính năng trả phí | {unlocked} trên {paid} tính năng trả phí",
"features_unlocked": "Đã mở khóa",
"features_missing": "Không có trong giấy phép của bạn",
"features_request_access": "Yêu cầu truy cập",
"features_search": "Tìm kiếm {count} tính năng | Tìm kiếm tất cả {count} tính năng",
"features_filter_all": "Tất cả",
"features_filter_free": "Miễn phí",
"features_group_heading": "{heading} · {count}",
"features_no_match": "Không có tính năng nào khớp với tìm kiếm của bạn",
"features_none": "Không có tính năng nào trong nhóm này",
"features_free_heading": "Miễn phí, có trong mọi giấy phép",
"features_new": "Mới sau giấy phép của bạn",
"features_filter_new": "Mới",
"request_new_license": "Yêu cầu giấy phép mới",
"add_new_license_key": "Thêm khóa giấy phép mới",
"refresh_license_limits": "Làm mới giới hạn giấy phép",
@@ -9928,33 +9899,184 @@
"scrollLeft": "Cuộn dịch vụ sang trái",
"scrollRight": "Cuộn dịch vụ sang phải",
"untitledIncident": "Sự cố chưa có tiêu đề",
"allClear": "Tất cả hệ thống ổn định",
"allClearDesc": "Không có bất thường, sự cố hoặc dịch vụ suy giảm nào đang hoạt động trong khoảng thời gian đã chọn. Cơ sở hạ tầng của bạn có vẻ khỏe mạnh.",
"allClearDescScoped": "Không có sự cố đang mở, bất thường hoặc dịch vụ bị suy giảm trong khoảng thời gian đã chọn.",
"allClearPartial": "Không có gì để báo cáo",
"allClearPartialDesc": "Không có gì cần chú ý. Chúng tôi không kiểm tra {areas}, vì giấy phép của bạn không bao gồm các tính năng đó.",
"areaAlerts": "Cảnh báo",
"areaAnomalies": "Phát hiện bất thường",
"areaIncidents": "Sự cố",
"areaServices": "Dịch vụ",
"notMonitoredTitle": "Chưa có gì được theo dõi",
"notMonitoredDesc": "Dữ liệu của bạn đang đến, nhưng không có cảnh báo hoặc kiểm tra bất thường nào chạy trên đó. Thêm một cái và Tổng quan sẽ liệt kê mọi thứ cần chú ý.",
"notMonitoredAlert": "Tạo cảnh báo",
"notMonitoredAlertDesc": "Nhận thông báo khi một truy vấn vượt qua ngưỡng",
"notMonitoredAnomaly": "Thiết lập phát hiện bất thường",
"notMonitoredAnomalyDesc": "Đánh dấu khối lượng bất thường mà không cần chọn ngưỡng",
"emptyActionAlerts": "Xem quy tắc cảnh báo",
"emptyActionAlertsDesc": "Xem lại các cảnh báo và ngưỡng đã cấu hình",
"emptyActionLogs": "Khám phá nhật ký",
"emptyActionLogsDesc": "Tìm kiếm và lọc các stream log của bạn",
"emptyActionTraces": "Khám phá trace",
"emptyActionTracesDesc": "Kiểm tra trace phân tán và độ trễ dịch vụ",
"anomalyDetectedTitle": "Đã phát hiện bất thường — {name}",
"anomalyDetectedDescription": "Stream: {stream} · {count} điểm bất thường",
"secondsAgo": "{count} giây trước",
"minutesAgo": "{count} phút trước",
"hoursAgo": "{count} giờ trước",
"daysAgo": "{count} ngày trước"
"daysAgo": "{count} ngày trước",
"cards": {
"firingAlerts": "Cảnh báo đang kích hoạt",
"failingRules": "Quy tắc đang lỗi",
"openIncidents": "Sự cố đang mở",
"pipelineErrors": "Lỗi pipeline",
"pipelinesOf": "trong {enabled} đã bật",
"quietStreams": "Stream không hoạt động",
"firingNone": "Không có cảnh báo kích hoạt",
"noAlertRules": "Không có quy tắc cảnh báo",
"noEnabledRules": "Không có quy tắc nào được bật",
"failingNone": "Không có quy tắc lỗi",
"failingNote": "Lần đánh giá gần nhất bị lỗi",
"incidentsNone": "Không có sự cố đang mở",
"noPipelines": "Không có pipeline",
"noStreams": "Không có stream",
"quietNote": "Ngừng ghi dữ liệu trong 7 ngày qua",
"criticalUnit": "{count} ở mức nghiêm trọng",
"deliveryFailures": "Lỗi gửi cảnh báo",
"deliveryNote": "Cảnh báo đã kích hoạt nhưng không thể thông báo",
"oldestFiring": "Kích hoạt lâu nhất trong {age}"
},
"state": {
"tileForbidden": "Không có quyền truy cập",
"tileLocked": "Đã khóa",
"tileError": "Không thể tải",
"tileUnavailable": "Không khả dụng",
"sectionLocked": "Khả dụng với giấy phép doanh nghiệp",
"sectionForbidden": "Vai trò của bạn không thể xem dữ liệu này. Hãy yêu cầu quản trị viên tổ chức cấp quyền truy cập."
},
"time": {
"minutes": "{minutes} phút",
"hoursMinutes": "{hours} giờ {minutes} phút",
"daysHours": "{days} ngày {hours} giờ"
},
"level": {
"critical": "Nghiêm trọng",
"warning": "Cảnh báo",
"okFiring": "Đang kích hoạt"
},
"sections": {
"alerts": "Cảnh báo",
"dataHealth": "Tình trạng dữ liệu",
"alertsAndIncidents": "Cảnh báo và sự cố",
"limitAndStorage": "Giới hạn tiếp nhận dữ liệu và lưu trữ",
"storage": "Kích thước dữ liệu",
"topStreams": "Stream hàng đầu theo kích thước dữ liệu"
},
"alerts": {
"firingFor": "Đang kích hoạt trong",
"showMore": "Hiển thị thêm {count} cảnh báo đang kích hoạt | Hiển thị thêm {count} cảnh báo đang kích hoạt",
"emptyTitle": "Chưa có quy tắc cảnh báo",
"emptyDesc": "Các quy tắc bạn tạo sẽ hiển thị tại đây, cùng với những gì đang kích hoạt theo từng mức và những gì đang lỗi khi chạy.",
"emptyAction": "Tạo cảnh báo",
"criticalLevel": "Mức nghiêm trọng",
"warningLevel": "Mức cảnh báo",
"rulesFailing": "Quy tắc đang lỗi",
"firingNow": "Đang kích hoạt, cũ nhất trước",
"noneFiring": "Không có cảnh báo nào đang kích hoạt",
"rulesActive": "{count} quy tắc đang hoạt động | {count} quy tắc đang hoạt động",
"rulesFailingToEvaluate": "{count} quy tắc lỗi đánh giá | {count} quy tắc lỗi đánh giá",
"subtitle": "{active} · {failing}",
"viewAll": "Xem tất cả cảnh báo",
"rulesEvaluated": "{count} quy tắc đang được đánh giá | {count} quy tắc đang được đánh giá"
},
"dataHealth": {
"viewPipelines": "Xem pipeline",
"noPipelines": "Chưa có pipeline nào.",
"pipelinesErrored": "Pipeline có lỗi",
"pipelinesNote": "Pipeline đang bật có lỗi",
"quiet": "Stream đã ngừng ghi",
"quietNote": "Ngừng ghi trong khoảng từ 1 giờ đến 7 ngày trước",
"failing": "Quy tắc cảnh báo lỗi đánh giá",
"failingNote": "Điểm mù, không kích hoạt",
"delivery": "Lỗi gửi cảnh báo",
"deliveryNote": "Cảnh báo đã kích hoạt nhưng không thể thông báo",
"schema": "Thay đổi schema trong 24 giờ",
"schemaNote": "Phiên bản schema mới trên các stream của bạn",
"status": {
"needsAction": "Cần xử lý",
"warning": "Cảnh báo",
"healthy": "Bình thường",
"notSetUp": "Chưa thiết lập",
"info": "Thông tin"
},
"compaction": "Các tác vụ compaction đang chờ",
"compactionNote": "Trên các loại stream bạn có thể xem",
"pipelinesActive": "Trong {count} pipeline đang hoạt động | Trong {count} pipeline đang hoạt động",
"subtitle": "Dữ liệu có đến nguyên vẹn và được gửi đi không?"
},
"streams": {
"emptyTitle": "Chưa có stream nào",
"emptyDesc": "Stream sẽ xuất hiện ở đây khi dữ liệu bắt đầu đến.",
"startIngesting": "Bắt đầu tiếp nhận dữ liệu",
"allStreams": "Tất cả stream",
"shareOfTotal": "{percent}% tổng số",
"shareUnderOne": "Dưới 1% tổng số",
"subtitle": "Các stream lớn nhất bạn có thể truy cập",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "Mở",
"openAria": {
"logs": "Mở nhật ký",
"metrics": "Mở chỉ số",
"traces": "Mở trace"
},
"logs": "Nhật ký",
"metrics": "Chỉ số",
"traces": "Trace",
"streams": "Stream",
"events": "Sự kiện",
"samples": "Mẫu",
"spans": "Span",
"stored": "Kích thước dữ liệu"
},
"storage": {
"emptyTitle": "Chưa có dữ liệu",
"bySignal": "Kích thước dữ liệu theo tín hiệu",
"splitNote": "Kích thước chưa nén trên nhật ký, chỉ số và trace",
"used": "Kích thước dữ liệu"
},
"limit": {
"used": "Đã tiếp nhận hôm nay (UTC)",
"ofLimit": "trên {limit}",
"daysOver": "Số ngày vượt hạn mức trong tháng này",
"approachingLimit": "Sắp đạt hạn mức",
"overLimit": "Vượt hạn mức",
"ofUnlimited": "trên không giới hạn",
"unavailable": "Không có hạn mức tiếp nhận",
"blockNote": "Tìm kiếm bị chặn sau khi vượt hạn mức quá {allowed} ngày | Tìm kiếm bị chặn sau khi vượt hạn mức quá {allowed} ngày",
"daysOverOf": "{days} trên {allowed} được phép",
"expiredLine": "Giấy phép đã hết hạn",
"resets": "Đặt lại lúc 00:00 UTC, sau {countdown}",
"subtitle": "Hạn mức hàng ngày tính theo ngày UTC, không phải 24 giờ trượt"
},
"empty": {
"incidents": "Không có sự cố nào đang mở",
"anomalies": "Không phát hiện bất thường nào trong khoảng thời gian đã chọn",
"anomalyNoChecks": "Chưa thiết lập kiểm tra bất thường nào",
"services": "Không có dữ liệu dịch vụ trong khoảng thời gian đã chọn",
"recentEvents": "Không có sự kiện cảnh báo trong khoảng thời gian đã chọn"
},
"stale": {
"message": "Đang hiển thị dữ liệu từ {time}. Làm mới thất bại.",
"retry": "Thử lại"
},
"dataAsOf": "Dữ liệu tính đến {time}",
"status": {
"attention": "Cần chú ý",
"checking": "Đang kiểm tra…",
"partial": "Không tải được một số kiểm tra",
"clear": "Không có gì cần chú ý",
"items": {
"firing": "{count} cảnh báo đang kích hoạt | {count} cảnh báo đang kích hoạt",
"failing": "{count} quy tắc bị lỗi | {count} quy tắc bị lỗi",
"incidents": "{count} sự cố đang mở | {count} sự cố đang mở",
"pipelines": "{count} pipeline có lỗi | {count} pipeline có lỗi",
"quiet": "{count} stream im lặng | {count} stream im lặng"
},
"unmonitored": "Chưa có gì được giám sát"
},
"timeRange": {
"heading": "Trong khoảng thời gian đã chọn"
}
},
"chatHistory": {
"title": "Trò chuyện",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "Khởi động lại {count} collector?",
"restartMessage": "Mỗi collector khởi động lại ở thông điệp kế tiếp từ máy chủ. Việc thu thập dữ liệu tạm dừng ngắn trên mỗi máy chủ.",
"restartQueued": "Đã yêu cầu khởi động lại {count} collector | Đã yêu cầu khởi động lại {count} collector",
"restartOffline": "Collector này đang ngoại tuyến. Hãy khởi động lại khi nó hoạt động trở lại.",
"restartUnsupported": "Collector này không chấp nhận lệnh khởi động lại",
"systemSections": "Phần do hệ thống quản lý",
"systemSectionsCaption": "Tiện ích mở rộng OpAMP và dữ liệu đo từ xa của dịch vụ, do supervisor và máy chủ thiết lập",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "Mở trình chỉnh sửa {config}. Hãy phát hành một phiên bản đã sửa và đổi fleet này sang phiên bản đó; đợt triển khai này khi đó sẽ bị thay thế.",
"confirmResume": "Cùng đợt triển khai này tiếp tục từ giai đoạn {n}.",
"confirmRollback": "Bắt đầu ngay một đợt triển khai quay lui về phiên bản ổn định, {config} v{version}. Các collector đã ở phiên bản đó sẽ không nhận gì.",
"confirmRollbackNoStable": "Fleet này không có phiên bản ổn định, nên quá trình triển khai kết thúc và các collector của nó ngừng thu thập cho đến khi bạn triển khai lại.",
"confirmRollbackNoStable": "Bắt đầu ngay một đợt triển khai quay lui về phiên bản ổn định.",
"confirmSkip": "Loại trừ {count} collector bị lỗi và tiếp tục. Nó được đánh dấu là bị loại trừ và được đưa vào lại ở đợt triển khai kế tiếp. | Loại trừ {count} collector bị lỗi và tiếp tục. Chúng được đánh dấu là bị loại trừ và được đưa vào lại ở đợt triển khai kế tiếp.",
"confirmed": "Đã xác nhận",
"eventConverged": "Giai đoạn {n} đã hội tụ",
@@ -29358,7 +29479,6 @@
"phaseN": "Giai đoạn {n}",
"phaseTimedOut": "Hết thời gian chờ",
"phaseTimeoutNote": "Giai đoạn không xác nhận cũng không từ chối trong vòng {minutes} phút sẽ làm đợt triển khai dừng (cài đặt fleet)",
"phaseTimeoutNoteSeconds": "Giai đoạn không xác nhận cũng không từ chối trong {minutes} phút {seconds} giây sẽ tạm dừng quá trình triển khai (cài đặt fleet)",
"phaseVerifying": "Đang xác minh",
"phases": "Các giai đoạn",
"planEmpty": "Không có collector nào trực tuyến, nên đợt triển khai chờ đến khi có một collector kết nối",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "Kế thừa ({value})",
"offlineHeartbeats": "Ngoại tuyến sau số heartbeat bị lỡ",
"offlineHeartbeatsHelp": "Collector bị coi là ngoại tuyến sau chừng này chu kỳ heartbeat không có thông điệp",
"offlineRatio": "Tỷ lệ ngoại tuyến (%)",
"offlineRatioHelp": "Tạm dừng giai đoạn khi nhiều hơn tỷ lệ này trong số các collector của giai đoạn đang ngoại tuyến",
"offlineRatio": "Sự kiện tỷ lệ ngoại tuyến (%)",
"offlineRatioHelp": "Ghi lại một sự kiện fleet khi hơn tỷ lệ này số collector ngoại tuyến",
"outOfRange": "Nhập một số từ {min} đến {max}",
"phaseTimeout": "Thời gian chờ giai đoạn (giây)",
"phaseTimeoutHelp": "Giai đoạn triển khai không xác nhận cũng không từ chối trong thời gian này sẽ dừng",
+170 -50
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "不在您的许可证中",
"title": "{feature} 未包含在您的许可证中",
"description": "您的管理员可以通过更新许可证来添加它,或联系销售获取包含该功能的许可证。",
"thisFeature": "此功能",
"contactSales": "联系销售",
"toastTitle": "未包含在您的许可证中",
"toastMessage": "您的许可证不包含:{features}。请让管理员更新许可证。",
"toastMessageGeneric": "您的许可证不包含此功能。请让管理员更新许可证。",
"bannerMessage": "此功能已锁定。需要您的许可证包含 {feature} 功能。",
"bannerMessageGeneric": "此页面需要一项不在您许可证中的功能。",
"headerLocked": "已锁定:需要 {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "暂无历史记录",
@@ -3420,22 +3407,6 @@
"contact_name": "联系人姓名",
"contact_email": "联系人邮箱",
"environment_type": "环境类型",
"features": "功能",
"features_all_unlocked": "您的许可证已解锁所有功能。",
"features_summary": "您的许可证解锁了 {ratio}。始终包含 {free} 项免费功能。 | 您的许可证解锁了 {ratio}。始终包含 {free} 项免费功能。",
"features_paid_ratio": "已解锁 {unlocked}/{paid} 项付费功能 | 已解锁 {unlocked}/{paid} 项付费功能",
"features_unlocked": "已解锁",
"features_missing": "不在您的许可证中",
"features_request_access": "申请访问权限",
"features_search": "搜索 {count} 项功能 | 搜索所有 {count} 项功能",
"features_filter_all": "全部",
"features_filter_free": "免费",
"features_group_heading": "{heading} · {count}",
"features_no_match": "没有功能与您的搜索匹配",
"features_none": "此组中没有功能",
"features_free_heading": "免费,所有许可证均包含",
"features_new": "许可证之后新增",
"features_filter_new": "新增",
"request_new_license": "申请新许可证",
"add_new_license_key": "添加新的许可证密钥",
"refresh_license_limits": "刷新许可证限制",
@@ -9918,7 +9889,7 @@
"activeAnomalies": "活跃异常",
"activeIncidents": "活跃事件",
"services": "服务",
"recentEvents": "最近的事件",
"recentEvents": "最近事件",
"investigate": "调查 →",
"viewAll": "查看全部",
"showingOf": "显示 {shown}/{total}",
@@ -9927,34 +9898,185 @@
"colReqs": "要求",
"scrollLeft": "向左滚动服务",
"scrollRight": "向右滚动服务",
"untitledIncident": "无题事件",
"allClear": "全部清除",
"allClearDesc": "在所选时间窗口内没有活跃的异常、事件或降级的服务。",
"allClearDescScoped": "所选时间范围内没有未解决的事件、异常或性能下降的服务。",
"allClearPartial": "无可报告内容",
"allClearPartialDesc": "没有需要关注的内容。我们未检查 {areas},因为您的许可证不包含这些功能。",
"areaAlerts": "告警",
"areaAnomalies": "异常检测",
"areaIncidents": "事件",
"areaServices": "服务",
"untitledIncident": "未命名事件",
"notMonitoredTitle": "尚无任何被监控项",
"notMonitoredDesc": "您的数据正在到达,但没有对其运行任何告警或异常检查。添加一个后,概览会列出所有需要关注的内容。",
"notMonitoredAlert": "创建告警",
"notMonitoredAlertDesc": "当查询超过阈值时收到通知",
"notMonitoredAnomaly": "设置异常检测",
"notMonitoredAnomalyDesc": "无需设置阈值即可标记异常量",
"emptyActionAlerts": "查看告警规则",
"emptyActionAlertsDesc": "查看已配置的告警和阈值",
"emptyActionLogs": "浏览日志",
"emptyActionLogsDesc": "搜索和筛选您的日志流",
"emptyActionTraces": "探索追踪",
"emptyActionTracesDesc": "检查分布式追踪和服务延迟",
"anomalyDetectedTitle": "检测到异常 — {name}",
"anomalyDetectedDescription": "流:{stream} · {count} 个异常点",
"secondsAgo": "{count} 秒前",
"minutesAgo": "{count} 分钟前",
"hoursAgo": "{count} 小时前",
"daysAgo": "{count} 天前"
"daysAgo": "{count} 天前",
"cards": {
"firingAlerts": "触发中的告警",
"failingRules": "失败的规则",
"openIncidents": "未解决事件",
"pipelineErrors": "流水线错误",
"pipelinesOf": "共 {enabled} 个已启用",
"quietStreams": "静默数据流",
"firingNone": "无触发",
"noAlertRules": "无告警规则",
"noEnabledRules": "无已启用规则",
"failingNone": "无失败",
"failingNote": "上次评估出错",
"incidentsNone": "无未解决",
"noPipelines": "无流水线",
"noStreams": "无数据流",
"quietNote": "过去 7 天内停止写入",
"criticalUnit": "{count} 个处于严重级别",
"deliveryFailures": "告警投递失败",
"deliveryNote": "已触发但无法通知的告警",
"oldestFiring": "最早触发已持续 {age}"
},
"state": {
"tileForbidden": "无访问权限",
"tileLocked": "已锁定",
"tileError": "无法加载",
"tileUnavailable": "不可用",
"sectionLocked": "需企业许可证方可使用",
"sectionForbidden": "您的角色无法查看此数据。请联系组织管理员获取访问权限。"
},
"time": {
"minutes": "{minutes} 分钟",
"hoursMinutes": "{hours} 小时 {minutes} 分钟",
"daysHours": "{days} 天 {hours} 小时"
},
"level": {
"critical": "严重",
"warning": "警告",
"okFiring": "触发中"
},
"sections": {
"alerts": "告警",
"dataHealth": "数据健康",
"alertsAndIncidents": "告警与事件",
"limitAndStorage": "摄取限制与存储",
"storage": "数据大小",
"topStreams": "按数据大小排名的数据流"
},
"alerts": {
"firingFor": "已触发时长",
"showMore": "再显示 {count} 个触发中的告警 | 再显示 {count} 个触发中的告警",
"emptyTitle": "尚无告警规则",
"emptyDesc": "您创建的规则会显示在此处,包括按级别划分的触发情况和运行失败的规则。",
"emptyAction": "创建告警",
"criticalLevel": "严重级别",
"warningLevel": "警告级别",
"rulesFailing": "失败的规则",
"firingNow": "当前触发中,最早的在前",
"noneFiring": "当前没有告警触发中",
"rulesActive": "{count} 条规则已启用 | {count} 条规则已启用",
"rulesFailingToEvaluate": "{count} 条评估失败 | {count} 条评估失败",
"subtitle": "{active} · {failing}",
"viewAll": "查看所有告警",
"rulesEvaluated": "{count} 条规则正在评估 | {count} 条规则正在评估"
},
"dataHealth": {
"viewPipelines": "查看流水线",
"noPipelines": "暂无流水线。",
"pipelinesErrored": "出错的流水线",
"pipelinesNote": "已启用但出错的流水线",
"quiet": "已静默的数据流",
"quietNote": "在 1 小时至 7 天前停止写入",
"failing": "评估失败的告警规则",
"failingNote": "监控盲区,未触发",
"delivery": "告警投递失败",
"deliveryNote": "已触发但无法通知的告警",
"schema": "24 小时内的 Schema 变更",
"schemaNote": "各数据流中新增的 Schema 版本",
"status": {
"needsAction": "需要处理",
"warning": "警告",
"healthy": "健康",
"notSetUp": "未设置",
"info": "信息"
},
"compaction": "待处理的压缩任务",
"compactionNote": "涵盖您可见的各数据流类型",
"pipelinesActive": "在 {count} 条启用的流水线中 | 在 {count} 条启用的流水线中",
"subtitle": "数据是否在到达、完整且已投递?"
},
"streams": {
"emptyTitle": "暂无数据流",
"emptyDesc": "数据开始到达后,数据流将显示在这里。",
"startIngesting": "开始摄取",
"allStreams": "所有数据流",
"shareOfTotal": "占总量的 {percent}%",
"shareUnderOne": "低于总量的 1%",
"subtitle": "您可访问的最大数据流",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "打开",
"openAria": {
"logs": "打开日志",
"metrics": "打开指标",
"traces": "打开追踪"
},
"logs": "日志",
"metrics": "指标",
"traces": "追踪",
"streams": "数据流",
"events": "事件",
"samples": "样本",
"spans": "Span",
"stored": "数据大小"
},
"storage": {
"emptyTitle": "暂无数据",
"bySignal": "按信号划分的数据大小",
"splitNote": "日志、指标和追踪的未压缩大小",
"used": "数据大小"
},
"limit": {
"used": "今日已摄取(UTC)",
"ofLimit": "共 {limit}",
"daysOver": "本月超限天数",
"approachingLimit": "接近上限",
"overLimit": "超出上限",
"ofUnlimited": "无上限",
"unavailable": "摄取上限不可用",
"blockNote": "超过上限 {allowed} 天后搜索会被阻止 | 超过上限 {allowed} 天后搜索会被阻止",
"daysOverOf": "{days} / {allowed}(允许)",
"expiredLine": "许可证已过期",
"resets": "将于 00:00 UTC 重置,剩余 {countdown}",
"subtitle": "每日上限按 UTC 自然日计算,而非滚动 24 小时"
},
"empty": {
"incidents": "没有未解决的事件",
"anomalies": "所选时间范围内未检测到异常",
"anomalyNoChecks": "未设置异常检查",
"services": "所选时间范围内无服务数据",
"recentEvents": "所选时间范围内无告警事件"
},
"stale": {
"message": "正在显示 {time} 的数据。刷新失败。",
"retry": "重试"
},
"dataAsOf": "数据截至 {time}",
"status": {
"attention": "需要关注",
"checking": "检查中…",
"partial": "部分检查无法加载",
"clear": "无需关注",
"items": {
"firing": "{count} 条告警触发中 | {count} 条告警触发中",
"failing": "{count} 条规则失败 | {count} 条规则失败",
"incidents": "{count} 个未解决事件 | {count} 个未解决事件",
"pipelines": "{count} 条流水线有错误 | {count} 条流水线有错误",
"quiet": "{count} 个静默数据流 | {count} 个静默数据流"
},
"unmonitored": "尚未监控任何内容"
},
"timeRange": {
"heading": "在所选时间范围内"
}
},
"chatHistory": {
"title": "聊天",
@@ -29192,7 +29314,6 @@
"restartManyTitle": "重启 {count} 个 Collector?",
"restartMessage": "每个 Collector 将在收到服务器的下一条消息时重启。每台主机上的数据采集会短暂暂停。",
"restartQueued": "已请求重启 {count} 个 Collector | 已请求重启 {count} 个 Collector",
"restartOffline": "此采集器已离线。待其恢复在线后重启。",
"restartUnsupported": "此 Collector 不接受重启命令",
"systemSections": "系统管理的部分",
"systemSectionsCaption": "OpAMP 扩展和服务遥测,由 supervisor 和服务器设置",
@@ -29321,7 +29442,7 @@
"confirmFixForward": "打开 {config} 编辑器。发布修复后的版本并将此机群切换到该版本;此上线随后将被取代。",
"confirmResume": "同一上线将从阶段 {n} 继续。",
"confirmRollback": "立即开始回滚到稳定版本 {config} v{version}。已处于该版本的 Collector 不会收到任何内容。",
"confirmRollbackNoStable": "此 Fleet 没有稳定版本,因此发布将结束,其采集器将停止采集,直到您再次部署。",
"confirmRollbackNoStable": "立即开始回滚到稳定版本。",
"confirmSkip": "排除 {count} 个失败的 Collector 并继续。它将被标记为已排除,并在下次上线时重新纳入。 | 排除 {count} 个失败的 Collector 并继续。它们将被标记为已排除,并在下次上线时重新纳入。",
"confirmed": "已确认",
"eventConverged": "阶段 {n} 已收敛",
@@ -29358,7 +29479,6 @@
"phaseN": "阶段 {n}",
"phaseTimedOut": "已超时",
"phaseTimeoutNote": "阶段在 {minutes} 分钟内既未确认也未拒绝时,上线将暂停(机群设置)",
"phaseTimeoutNoteSeconds": "若某个阶段在 {minutes} 分 {seconds} 秒内既未确认也未拒绝,则暂停发布(Fleet 设置)",
"phaseVerifying": "验证中",
"phases": "阶段",
"planEmpty": "没有采集器在线,因此发布需等到有采集器连接后才继续",
@@ -29499,8 +29619,8 @@
"inheritPlaceholder": "继承({value})",
"offlineHeartbeats": "错过心跳后离线",
"offlineHeartbeatsHelp": "Collector 在这么多个心跳间隔内没有消息即视为离线",
"offlineRatio": "离线比例 (%)",
"offlineRatioHelp": "当处于离线状态的采集器占比超过该值时,暂停当前阶段",
"offlineRatio": "离线占比事件(%)",
"offlineRatioHelp": "当离线 Collector 的占比超过此值时记录机群事件",
"outOfRange": "请输入 {min} 到 {max} 之间的数字",
"phaseTimeout": "阶段超时(秒)",
"phaseTimeoutHelp": "上线阶段在此时间内既未确认也未拒绝则暂停",
+171 -51
View File
@@ -1,17 +1,4 @@
{
"featureLock": {
"routeTitle": "不在您的授權中",
"title": "{feature} 未包含在您的授權中",
"description": "您的管理員可透過更新授權來新增此功能,或聯絡業務取得包含此功能的授權。",
"thisFeature": "此功能",
"contactSales": "聯絡業務",
"toastTitle": "未包含在您的授權中",
"toastMessage": "您的授權不包含:{features}。請您的管理員更新授權。",
"toastMessageGeneric": "您的授權不包含此功能。請您的管理員更新授權。",
"bannerMessage": "此功能已上鎖。您的授權需要 {feature} 功能。",
"bannerMessageGeneric": "此頁面需要一項不在您授權中的功能。",
"headerLocked": "已上鎖:需要 {feature}"
},
"emptyState": {
"illustrations": {
"noHistoryYet": "尚無歷史記錄",
@@ -3420,22 +3407,6 @@
"contact_name": "聯絡人姓名",
"contact_email": "聯絡人電子郵件",
"environment_type": "環境類型",
"features": "功能",
"features_all_unlocked": "您的授權已解鎖所有功能。",
"features_summary": "您的授權解鎖了 {ratio}。永遠包含 {free} 項免費功能。 | 您的授權解鎖了 {ratio}。永遠包含 {free} 項免費功能。",
"features_paid_ratio": "{paid} 項付費功能中的 {unlocked} 項 | {paid} 項付費功能中的 {unlocked} 項",
"features_unlocked": "已解鎖",
"features_missing": "不在您的授權中",
"features_request_access": "申請存取權",
"features_search": "搜尋 {count} 項功能 | 搜尋全部 {count} 項功能",
"features_filter_all": "全部",
"features_filter_free": "免費",
"features_group_heading": "{heading} · {count}",
"features_no_match": "沒有符合您搜尋的功能",
"features_none": "此群組中沒有功能",
"features_free_heading": "免費,每份授權皆包含",
"features_new": "授權後新增",
"features_filter_new": "新增",
"request_new_license": "申請新授權",
"add_new_license_key": "新增授權金鑰",
"refresh_license_limits": "重新整理授權限制",
@@ -9910,10 +9881,10 @@
"alertsSuffix": "警示",
"viewingLabel": "— 檢視中",
"timesSymbol": "×",
"activeAnomalies": "作用中的異常",
"activeIncidents": "作用中的事件",
"activeAnomalies": "作用中異常",
"activeIncidents": "作用中事件",
"services": "服務",
"recentEvents": "近期事件",
"recentEvents": "最近事件",
"investigate": "調查 →",
"viewAll": "檢視全部",
"showingOf": "顯示 {shown} / {total}",
@@ -9923,33 +9894,184 @@
"scrollLeft": "向左捲動服務",
"scrollRight": "向右捲動服務",
"untitledIncident": "未命名事件",
"allClear": "一切正常",
"allClearDesc": "在選取的時間區段中沒有作用中的異常、事件或降級服務。",
"allClearDescScoped": "在所選時間範圍內沒有未解決的事件、異常或降級的服務。",
"allClearPartial": "沒有需要回報的事項",
"allClearPartialDesc": "沒有需要關注的事項。我們未檢查 {areas},因為您的授權不包含這些功能。",
"areaAlerts": "警示",
"areaAnomalies": "異常偵測",
"areaIncidents": "事件",
"areaServices": "服務",
"notMonitoredTitle": "尚未監控任何項目",
"notMonitoredDesc": "你的資料正在送達,但沒有任何警示或異常檢查在執行。新增一個,總覽就會列出需要關注的項目。",
"notMonitoredAlert": "建立警示",
"notMonitoredAlertDesc": "當查詢超過閾值時收到通知",
"notMonitoredAnomaly": "設定異常偵測",
"notMonitoredAnomalyDesc": "不必設定閾值也能標示異常的量",
"emptyActionAlerts": "查看警示規則",
"emptyActionAlertsDesc": "檢視已設定的警示與閾值",
"emptyActionLogs": "探索日誌",
"emptyActionLogsDesc": "搜尋並篩選您的日誌串流",
"emptyActionTraces": "探索追蹤",
"emptyActionTracesDesc": "檢查分散式追蹤與服務延遲",
"anomalyDetectedTitle": "偵測到異常 — {name}",
"anomalyDetectedDescription": "串流:{stream} · {count} 個異常點",
"secondsAgo": "{count} 秒前",
"minutesAgo": "{count} 分鐘前",
"hoursAgo": "{count} 小時前",
"daysAgo": "{count} 天前"
"daysAgo": "{count} 天前",
"cards": {
"firingAlerts": "觸發中警示",
"failingRules": "失敗規則",
"openIncidents": "未解決事件",
"pipelineErrors": "管線錯誤",
"pipelinesOf": "已啟用 {enabled} 個",
"quietStreams": "沉寂串流",
"firingNone": "目前無觸發",
"noAlertRules": "無警示規則",
"noEnabledRules": "無已啟用規則",
"failingNone": "目前無失敗",
"failingNote": "上次評估發生錯誤",
"incidentsNone": "目前無未解決事件",
"noPipelines": "無管線",
"noStreams": "無串流",
"quietNote": "過去 7 天已停止寫入",
"criticalUnit": "{count} 個為嚴重等級",
"deliveryFailures": "警示傳送失敗",
"deliveryNote": "已觸發但無法通知的警示",
"oldestFiring": "最久觸發已持續 {age}"
},
"state": {
"tileForbidden": "無存取權",
"tileLocked": "已鎖定",
"tileError": "無法載入",
"tileUnavailable": "無法使用",
"sectionLocked": "需企業授權才能使用",
"sectionForbidden": "您的角色無法查看此數據。請聯絡組織管理員取得存取權。"
},
"time": {
"minutes": "{minutes} 分鐘",
"hoursMinutes": "{hours} 小時 {minutes} 分鐘",
"daysHours": "{days} 天 {hours} 小時"
},
"level": {
"critical": "嚴重",
"warning": "警告",
"okFiring": "觸發中"
},
"sections": {
"alerts": "警示",
"dataHealth": "數據健康度",
"alertsAndIncidents": "警示與事件",
"limitAndStorage": "擷取上限與儲存",
"storage": "數據大小",
"topStreams": "數據大小最大的串流"
},
"alerts": {
"firingFor": "觸發持續時間",
"showMore": "再顯示 {count} 個觸發中警示 | 再顯示 {count} 個觸發中警示",
"emptyTitle": "尚無警示規則",
"emptyDesc": "您建立的規則會顯示在這裡,包含各層級目前觸發中的項目,以及執行失敗的項目。",
"emptyAction": "建立警示",
"criticalLevel": "嚴重等級",
"warningLevel": "警告等級",
"rulesFailing": "執行失敗的規則",
"firingNow": "目前觸發中,最久優先",
"noneFiring": "目前沒有警示觸發中",
"rulesActive": "{count} 條規則啟用中 | {count} 條規則啟用中",
"rulesFailingToEvaluate": "{count} 項評估失敗 | {count} 項評估失敗",
"subtitle": "{active} · {failing}",
"viewAll": "檢視所有警示",
"rulesEvaluated": "{count} 條規則正在評估 | {count} 條規則正在評估"
},
"dataHealth": {
"viewPipelines": "檢視管線",
"noPipelines": "尚無管線。",
"pipelinesErrored": "發生錯誤的管線",
"pipelinesNote": "已啟用但發生錯誤的管線",
"quiet": "停止寫入的串流",
"quietNote": "在 1 小時至 7 天前停止寫入",
"failing": "評估失敗的警示規則",
"failingNote": "盲區,未觸發",
"delivery": "警示傳送失敗",
"deliveryNote": "已觸發但無法通知的警示",
"schema": "24 小時內的結構描述變更",
"schemaNote": "各串流中的新結構描述版本",
"status": {
"needsAction": "需要處理",
"warning": "警告",
"healthy": "健康",
"notSetUp": "尚未設定",
"info": "資訊"
},
"compaction": "待處理的壓縮作業",
"compactionNote": "涵蓋您可見的串流類型",
"pipelinesActive": "共 {count} 條啟用中的管線 | 共 {count} 條啟用中的管線",
"subtitle": "數據是否正常抵達、保持完整並成功送達?"
},
"streams": {
"emptyTitle": "尚無串流",
"emptyDesc": "數據開始抵達後,串流就會顯示在這裡。",
"startIngesting": "開始擷取",
"allStreams": "所有串流",
"shareOfTotal": "佔總計 {percent}%",
"shareUnderOne": "佔總計不到 1%",
"subtitle": "您可存取的最大串流",
"typeShare": "{type} · {share}"
},
"signals": {
"open": "開啟",
"openAria": {
"logs": "開啟日誌",
"metrics": "開啟指標",
"traces": "開啟追蹤"
},
"logs": "日誌",
"metrics": "指標",
"traces": "追蹤",
"streams": "串流",
"events": "事件",
"samples": "樣本",
"spans": "Span",
"stored": "數據大小"
},
"storage": {
"emptyTitle": "尚無數據",
"bySignal": "依訊號劃分的數據大小",
"splitNote": "日誌、指標與追蹤的未壓縮大小",
"used": "資料大小"
},
"limit": {
"used": "今日擷取量 (UTC)",
"ofLimit": "共 {limit}",
"daysOver": "本月超限天數",
"approachingLimit": "接近上限",
"overLimit": "超出上限",
"ofUnlimited": "無上限",
"unavailable": "無法取得擷取上限",
"blockNote": "超出上限超過 {allowed} 天後,搜尋將被封鎖 | 超出上限超過 {allowed} 天後,搜尋將被封鎖",
"daysOverOf": "{days}/{allowed}(允許)",
"expiredLine": "授權已過期",
"resets": "將於 00:00 UTC 重設,倒數 {countdown}",
"subtitle": "每日上限以 UTC 日計算,而非滾動 24 小時"
},
"empty": {
"incidents": "沒有未解決事件",
"anomalies": "所選時間範圍內未偵測到異常",
"anomalyNoChecks": "尚未設定異常檢查",
"services": "所選時間範圍內沒有服務資料",
"recentEvents": "所選時間範圍內沒有警示事件"
},
"stale": {
"message": "顯示 {time} 的資料。重新整理失敗。",
"retry": "重試"
},
"dataAsOf": "資料截至 {time}",
"status": {
"attention": "需要留意",
"checking": "檢查中…",
"partial": "部分檢查無法載入",
"clear": "沒有需要留意的事項",
"items": {
"firing": "{count} 個警示觸發中 | {count} 個警示觸發中",
"failing": "{count} 條規則失敗 | {count} 條規則失敗",
"incidents": "{count} 個未解決事件 | {count} 個未解決事件",
"pipelines": "{count} 條管線有錯誤 | {count} 條管線有錯誤",
"quiet": "{count} 個閒置串流 | {count} 個閒置串流"
},
"unmonitored": "尚未監控任何項目"
},
"timeRange": {
"heading": "所選時間範圍內"
}
},
"chatHistory": {
"title": "對話",
@@ -29187,7 +29309,6 @@
"restartManyTitle": "要重新啟動 {count} 個收集器嗎?",
"restartMessage": "每個收集器會在收到伺服器的下一則訊息時重新啟動。每台主機的資料收集會短暫暫停。",
"restartQueued": "已要求重新啟動 {count} 個收集器 | 已要求重新啟動 {count} 個收集器",
"restartOffline": "此收集器處於離線狀態。待其恢復上線後請重新啟動。",
"restartUnsupported": "此收集器不接受重新啟動指令",
"systemSections": "系統管理區段",
"systemSectionsCaption": "OpAMP 擴充功能與服務遙測,由 supervisor 與伺服器設定",
@@ -29316,7 +29437,7 @@
"confirmFixForward": "開啟 {config} 編輯器。請發佈修正後的版本並將此機群變更為該版本;屆時此推出將被取代。",
"confirmResume": "同一個推出會從第 {n} 階段繼續。",
"confirmRollback": "立即開始推出以回復到穩定版本 {config} v{version}。已在該版本的收集器不會收到任何內容。",
"confirmRollbackNoStable": "此機群沒有穩定版本,因此推出會結束,其收集器會停止收集,直到您再次部署為止。",
"confirmRollbackNoStable": "立即開始推出以回復到穩定版本。",
"confirmSkip": "排除 {count} 個失敗的收集器並繼續。它會被標記為已排除,並在下次推出時重新納入。 | 排除 {count} 個失敗的收集器並繼續。它們會被標記為已排除,並在下次推出時重新納入。",
"confirmed": "已確認",
"eventConverged": "第 {n} 階段已收斂",
@@ -29353,7 +29474,6 @@
"phaseN": "第 {n} 階段",
"phaseTimedOut": "已逾時",
"phaseTimeoutNote": "若某階段在 {minutes} 分鐘內既未確認也未拒絕,推出將中止(機群設定)",
"phaseTimeoutNoteSeconds": "若某個階段在 {minutes} 分 {seconds} 秒內既未確認也未拒絕,就會中止推出(機群設定)",
"phaseVerifying": "驗證中",
"phases": "階段",
"planEmpty": "目前沒有收集器上線,因此部署會等到有收集器連線後才開始",
@@ -29494,8 +29614,8 @@
"inheritPlaceholder": "沿用({value})",
"offlineHeartbeats": "遺漏幾次心跳後視為離線",
"offlineHeartbeatsHelp": "收集器在這麼多個心跳間隔內未傳送任何訊息即視為離線",
"offlineRatio": "離線比例 (%)",
"offlineRatioHelp": "當離線的收集器占比超過此數值時,中止該階段",
"offlineRatio": "離線比例事件(%)",
"offlineRatioHelp": "當離線的收集器比例超過此值時,記錄一則機群事件",
"outOfRange": "請輸入 {min} 到 {max} 之間的數字",
"phaseTimeout": "階段逾時(秒)",
"phaseTimeoutHelp": "推出階段在此時間內既未確認也未拒絕即中止",
+57
View File
@@ -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 { describe, expect, it, vi } from "vitest";
import overview from "./overview";
import { OVERVIEW_TOP, orgOverviewQuery } from "./overview.queries";
import { overviewKeys } from "./overview.querykeys";
import { LIVE_STALE_TIME } from "@/composables/query/cachePolicy";
vi.mock("./overview", () => ({ default: { getOrgOverview: vi.fn() } }));
const BODY = { streams: { status: "ok" }, generated_at: 1, cached: false };
describe("orgOverviewQuery", () => {
it("keys per org, never by refresh", () => {
const normal = orgOverviewQuery("acme");
const forced = orgOverviewQuery("acme", { refresh: true });
expect(normal.queryKey).toEqual(overviewKeys.data("acme", OVERVIEW_TOP));
expect(forced.queryKey).toEqual(normal.queryKey);
expect(orgOverviewQuery("other").queryKey).not.toEqual(normal.queryKey);
});
it("uses the live freshness tier", () => {
expect(orgOverviewQuery("acme").staleTime).toBe(LIVE_STALE_TIME);
});
it("returns the response body unmapped and asks for top without refresh by default", async () => {
vi.mocked(overview.getOrgOverview).mockResolvedValue({ data: BODY } as never);
const result = await (orgOverviewQuery("acme").queryFn as () => Promise<unknown>)();
expect(result).toBe(BODY);
expect(overview.getOrgOverview).toHaveBeenCalledWith("acme", {
top: OVERVIEW_TOP,
refresh: undefined,
});
});
it("passes refresh=true on a forced read", async () => {
vi.mocked(overview.getOrgOverview).mockResolvedValue({ data: BODY } as never);
await (orgOverviewQuery("acme", { refresh: true }).queryFn as () => Promise<unknown>)();
expect(overview.getOrgOverview).toHaveBeenLastCalledWith("acme", {
top: OVERVIEW_TOP,
refresh: true,
});
});
});
+32
View File
@@ -0,0 +1,32 @@
// 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 { queryOptions } from "@tanstack/vue-query";
import overview from "./overview";
import { overviewKeys } from "./overview.querykeys";
import { LIVE_STALE_TIME } from "@/composables/query/cachePolicy";
import type { OrgOverview } from "@/ts/interfaces/overview";
// six per type so the top six streams across all types are exact; the firing list shows five of them
export const OVERVIEW_TOP = 6;
// `refresh` stays out of the key so a forced read replaces the cached entry instead of forking it.
export const orgOverviewQuery = (org: string, opts: { refresh?: boolean } = {}) =>
queryOptions({
queryKey: overviewKeys.data(org, OVERVIEW_TOP),
queryFn: async (): Promise<OrgOverview> =>
(await overview.getOrgOverview(org, { top: OVERVIEW_TOP, refresh: opts.refresh })).data,
staleTime: LIVE_STALE_TIME,
});
+20
View File
@@ -0,0 +1,20 @@
// 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 { orgKey } from "@/composables/query/keys";
export const overviewKeys = {
data: (org: string, top: number) => orgKey(org, "overview", "data", top),
};
+38
View File
@@ -0,0 +1,38 @@
// 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, vi } from "vitest";
import overview from "./overview";
const get = vi.fn().mockResolvedValue({ data: {} });
vi.mock("./http", () => ({ default: () => ({ get }) }));
describe("overview service", () => {
it("requests the org overview and keeps a 403 out of the access toast", async () => {
await overview.getOrgOverview("acme", { top: 5, refresh: true });
expect(get).toHaveBeenCalledWith("/api/acme/overview", {
params: { top: 5, refresh: true },
silentForbidden: true,
});
});
it("omits refresh and top when not asked", async () => {
await overview.getOrgOverview("acme");
expect(get).toHaveBeenLastCalledWith("/api/acme/overview", {
params: {},
silentForbidden: true,
});
});
});
+31
View File
@@ -0,0 +1,31 @@
// 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 http from "./http";
export interface OverviewParams {
top?: number;
refresh?: boolean;
}
const overview = {
getOrgOverview: (org: string, { top, refresh }: OverviewParams = {}) =>
http().get(`/api/${org}/overview`, {
params: { ...(top ? { top } : {}), ...(refresh ? { refresh: true } : {}) },
silentForbidden: true,
}),
};
export default overview;
+12
View File
@@ -170,6 +170,18 @@
);
}
/* At a fractional device pixel ratio the browser blurs a canvas by resampling it; print keeps smoothing since its scale is arbitrary. */
@utility chart-canvas-pixelated {
& canvas {
image-rendering: pixelated;
}
@media print {
& canvas {
image-rendering: auto;
}
}
}
/* Green-to-red scale for the click heat legend; hues mirror the canvas palette in utils/rum/clickHeatmap.ts. */
@utility bg-heat-legend {
background-image: linear-gradient(
@@ -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 { vi, type Mock } from "vitest";
export interface FakeCanvasLayer {
dpr: number;
ctx: { dpr: number };
resize: Mock;
}
export interface FakeCanvasPainter {
type: "canvas" | "svg";
dpr: number;
layers: Record<string, FakeCanvasLayer>;
getLayers: Mock;
getWidth: Mock;
getHeight: Mock;
refresh: Mock;
}
export interface DevicePixelRatioFake {
/** Moves the window to a display with this ratio, firing `change` on the queries that stop matching. */
setRatio: (next: number) => void;
/** How many `change` listeners are attached to resolution queries right now. */
listenerCount: () => number;
restore: () => void;
}
/** Drives `window.devicePixelRatio` and the `(resolution: Ndppx)` media queries the way a browser does. */
export const fakeDevicePixelRatio = (
initial: number,
{ legacyWebKit = false }: { legacyWebKit?: boolean } = {},
): DevicePixelRatioFake => {
let ratio = initial;
const listeners = new Map<string, Set<EventListener>>();
const ratioDescriptor = Object.getOwnPropertyDescriptor(window, "devicePixelRatio");
const realMatchMedia = window.matchMedia;
const queryFor = (r: number) =>
legacyWebKit ? `(-webkit-device-pixel-ratio: ${r})` : `(resolution: ${r}dppx)`;
Object.defineProperty(window, "devicePixelRatio", { configurable: true, get: () => ratio });
window.matchMedia = ((media: string) => {
// Breakpoint queries keep the shared mock so layout code sees the usual desktop.
if (!media.startsWith("(resolution:") && !media.startsWith("(-webkit-device-pixel-ratio:")) {
return realMatchMedia(media);
}
const add = (fn: EventListener) => {
if (!listeners.has(media)) listeners.set(media, new Set());
listeners.get(media)?.add(fn);
};
const remove = (fn: EventListener) => listeners.get(media)?.delete(fn);
// Safari before 16 matches only the prefixed query and exposes only addListener/removeListener.
const api = legacyWebKit
? { addListener: add, removeListener: remove }
: {
addEventListener: (_type: string, fn: EventListener) => add(fn),
removeEventListener: (_type: string, fn: EventListener) => remove(fn),
};
return { media, matches: media === queryFor(ratio), ...api } as unknown as MediaQueryList;
}) as typeof window.matchMedia;
return {
setRatio: (next) => {
const stale = queryFor(ratio);
ratio = next;
// Copied first: a handler re-arms itself while the set is being walked.
[...(listeners.get(stale) ?? [])].forEach((fn) => fn(new Event("change")));
},
listenerCount: () => [...listeners.values()].reduce((sum, set) => sum + set.size, 0),
restore: () => {
window.matchMedia = realMatchMedia;
if (ratioDescriptor) Object.defineProperty(window, "devicePixelRatio", ratioDescriptor);
else delete (window as { devicePixelRatio?: number }).devicePixelRatio;
},
};
};
/** A zrender canvas painter shaped like 6.1's: a base and a hover layer, each with its own context ratio. */
export const fakeCanvasPainter = (
dpr: number,
type: "canvas" | "svg" = "canvas",
): FakeCanvasPainter => {
const layer = (): FakeCanvasLayer => ({ dpr, ctx: { dpr }, resize: vi.fn() });
const layers = { "zr_0.0": layer(), "zr_100000.0": layer() };
return {
type,
dpr,
layers,
getLayers: vi.fn(() => layers),
getWidth: vi.fn(() => 400),
getHeight: vi.fn(() => 300),
refresh: vi.fn(),
};
};
+94
View File
@@ -0,0 +1,94 @@
// 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/>.
export type OverviewSectionStatus = "ok" | "error" | "locked" | "forbidden";
export interface OverviewSection<T> {
status: OverviewSectionStatus;
data?: T;
error?: string;
reason?: string;
}
export interface OverviewTopStream {
name: string;
doc_num: number;
storage_size_mb: number;
}
export interface OverviewStreamTypeSummary {
stream_type: string;
count: number;
doc_num: number;
storage_size_mb: number;
compressed_size_mb: number;
top: OverviewTopStream[];
}
export interface OverviewStreamsData {
total_streams: number;
total_storage_size_mb: number;
by_type: OverviewStreamTypeSummary[];
quiet_count: number;
schema_changes_24h: number;
compaction_pending: number | null;
}
export type OverviewAlertLevel = "ok" | "warning" | "critical" | "no_data" | "unclassified";
export interface OverviewFiringAlert {
name: string;
stream: string;
level: OverviewAlertLevel;
since: number;
}
export interface OverviewAlertsData {
total: number;
enabled: number;
disabled: number;
by_state: Record<string, number>;
by_level: Record<string, number>;
failed_evaluation: number;
firing: OverviewFiringAlert[];
firing_by_level: Record<OverviewAlertLevel, number>;
}
export interface OverviewPipelinesData {
total: number;
enabled: number;
disabled: number;
errored: number;
}
export interface OverviewLimitsData {
scope: string;
ingestion_used_percent: number;
expired: boolean;
days_over_limit: number;
used_today_gb: number | null;
limit_today_gb: number | null;
allowed_days: number | null;
resets_at: number;
}
export interface OrgOverview {
streams: OverviewSection<OverviewStreamsData>;
alerts: OverviewSection<OverviewAlertsData>;
pipelines: OverviewSection<OverviewPipelinesData>;
limits: OverviewSection<OverviewLimitsData>;
generated_at: number;
cached: boolean;
}
+1
View File
@@ -200,6 +200,7 @@ export function chartNumber(token: `--${string}`, fallback: number): number {
// Convenience getters used by the dashboard/trace chart converters.
export const chartTextColor = (): string => chartColor("--color-text-secondary");
export const chartAxisTextColor = (): string => chartColor("--color-text-body");
export const chartAxisLine = (): string => chartColor("--color-border-default");
export const chartBg = (): string => chartColor("--color-surface-base");
export const chartGridLine = (): string => chartColor("--color-border-subtle");
@@ -20,6 +20,7 @@ import { convertPromQLData } from "./convertPromQLData";
import { applyCartesianOptions, tooltipPointValue } from "./cartesianOptions";
import { toSeriesOverrideEntry } from "./seriesOverrides";
import { calculateWidthText } from "./chartDimensionUtils";
import { chartAxisTextColor } from "@/utils/chartTheme";
import * as echarts from "echarts";
// The custom-chart runner imports the ECharts bundle as a URL, which only resolves in a browser build.
@@ -426,6 +427,47 @@ describe("y_axis_label", () => {
expect(prom.yAxis.nameGap).toBeGreaterThan(0);
});
it("paints SQL and PromQL axis names in the body text colour", async () => {
const sql = await viaPanel(
sqlPanel("line", { y_axis_label: "Requests" }, { x: [timeX], y: [yField()] }),
[TIMES.map((t, i) => ({ x_axis_1: t, y_axis_1: i }))],
);
const prom = await viaPanel(promPanel("line", { y_axis_label: "Rate" }), promData());
const xAxis = sql.xAxis[0];
expect(xAxis.nameTextStyle.color).toBe(chartAxisTextColor());
expect(sql.yAxis.nameTextStyle.color).toBe(chartAxisTextColor());
expect(prom.xAxis.nameTextStyle.color).toBe(chartAxisTextColor());
expect(prom.yAxis.nameTextStyle.color).toBe(chartAxisTextColor());
});
it("paints SQL and PromQL x and y tick labels in the body text colour", async () => {
const color = chartAxisTextColor();
expect(color).toBe("#404040");
const charts = [
await viaPanel(sqlPanel("line", {}, { x: [timeX], y: [yField()] }), [
TIMES.map((t, i) => ({ x_axis_1: t, y_axis_1: i })),
]),
await viaPanel(sqlPanel("bar", {}, { x: [endpointX], y: [yField()] }), [plainCategoryRows()]),
await viaPanel(sqlPanel("h-bar", {}, { x: [endpointX], y: [yField()] }), [
plainCategoryRows(),
]),
await viaPanel(
sqlPanel("stacked", {}, { x: [endpointX], y: [yField()], breakdown: [breakdown] }),
[categoryRows()],
),
await viaPanel(promPanel("line"), promData()),
await viaPanel(promPanel("bar"), promData()),
await viaPanel(promPanel("h-bar"), promData()),
];
for (const options of charts) {
const axes = [options.xAxis, options.yAxis]
.flat()
.filter((axis: any) => axis?.show !== false);
expect(axes.length).toBeGreaterThanOrEqual(2);
for (const axis of axes) expect(axis.axisLabel.color).toBe(color);
}
});
it("keeps the name on the canvas with a log scale, spaced by the drawn ticks", async () => {
const panel = sqlPanel(
"line",
+7 -2
View File
@@ -14,7 +14,7 @@
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { escapeHtml } from "@/utils/html";
import { chartColor } from "@/utils/chartTheme";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { calculateNiceTickValues, calculateWidthText } from "./chartDimensionUtils";
import { absValue, createSeriesOverrideLookup, type SeriesOverrideLookup } from "./seriesOverrides";
import { sortTooltipParams } from "./tooltipSort";
@@ -444,7 +444,12 @@ const applyValueAxisLabel = (options: any, panelSchema: any) => {
const hadName = !!axis.name;
axis.name = label;
axis.nameLocation = "middle";
axis.nameTextStyle = { fontWeight: "bold", fontSize: 14, ...axis.nameTextStyle };
axis.nameTextStyle = {
color: chartAxisTextColor(),
fontWeight: "bold",
fontSize: 14,
...axis.nameTextStyle,
};
const grid = options.grid;
const canMoveGrid = grid && !Array.isArray(grid) && !isSet(config.axis_width);
if (horizontal) {
@@ -15,7 +15,7 @@
import { format as echartsFormat } from "echarts/core";
import { gt, raw } from "@/types/i18n";
import { chartColor } from "@/utils/chartTheme";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { escapeHtml } from "@/utils/html";
import { addCommasToNumber } from "@/utils/formatters";
import { formatAxisTickValue } from "./axisTickFormat";
@@ -289,7 +289,7 @@ export const convertBoxplotData = (
chartPanelRef?.value?.offsetWidth || FALLBACK_PANEL_WIDTH,
config,
),
color: chartColor("--color-text-secondary"),
color: chartAxisTextColor(),
},
splitLine: { show: false },
},
@@ -297,7 +297,7 @@ export const convertBoxplotData = (
type: "value",
scale: true,
axisLabel: {
color: chartColor("--color-text-secondary"),
color: chartAxisTextColor(),
formatter: (value: number) =>
formatAxisTickValue(value, config.unit, config.unit_custom, config.decimals),
},
@@ -15,7 +15,7 @@
import { format as echartsFormat } from "echarts/core";
import { gt } from "@/types/i18n";
import { chartColor } from "@/utils/chartTheme";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { escapeHtml } from "@/utils/html";
import { addCommasToNumber } from "@/utils/formatters";
import { formatAxisTickValue } from "./axisTickFormat";
@@ -210,7 +210,7 @@ export const convertDistributionData = (panelSchema: any, data: any, store: any)
max: buckets.start + buckets.count * buckets.size,
axisLabel: {
hideOverlap: true,
color: chartColor("--color-text-secondary"),
color: chartAxisTextColor(),
formatter: (value: number) => {
const text = formatAxisTickValue(value, config.unit, config.unit_custom, config.decimals);
return !config.unit || config.unit === "default" ? trimZeroFraction(text) : text;
@@ -221,9 +221,9 @@ export const convertDistributionData = (panelSchema: any, data: any, store: any)
yAxis: {
type: "value",
name: gt("dashboard.utils.distributionCount"),
nameTextStyle: { color: chartColor("--color-text-secondary") },
nameTextStyle: { color: chartAxisTextColor() },
minInterval: 1,
axisLabel: { color: chartColor("--color-text-secondary") },
axisLabel: { color: chartAxisTextColor() },
splitLine: {
show: config.show_gridlines !== false,
lineStyle: getGridLineStyle(theme),
+4 -3
View File
@@ -35,9 +35,9 @@ import { escapeHtml } from "@/utils/html";
import { addCommasToNumber } from "@/utils/formatters";
import {
chartAxisLine,
chartAxisTextColor,
chartColor,
chartNumber,
chartTextColor,
dataZoomBrushStyle,
CHART_SELECTION_FILL,
} from "@/utils/chartTheme";
@@ -868,6 +868,7 @@ export const convertPromQLData = async (
nameLocation: "middle",
nameGap: dynamicXAxisNameGap,
nameTextStyle: {
color: chartAxisTextColor(),
fontWeight: "bold",
fontSize: 14,
},
@@ -882,7 +883,7 @@ export const convertPromQLData = async (
lineStyle: gridLineStyle,
},
axisLabel: {
color: chartTextColor(),
color: chartAxisTextColor(),
fontSize: 12,
// hide axis label if overlaps
hideOverlap: true,
@@ -898,7 +899,7 @@ export const convertPromQLData = async (
min: getFinalAxisValue(panelSchema.config.y_axis_min, min, true),
max: getFinalAxisValue(panelSchema.config.y_axis_max, max, false),
axisLabel: {
color: chartTextColor(),
color: chartAxisTextColor(),
fontSize: 12,
// hide colliding tick labels instead of drawing them on top of each
// other (short panels fit fewer labels than ECharts offers)
@@ -16,7 +16,13 @@
import { gt } from "@/types/i18n";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import { format as echartsFormat } from "echarts/core";
import { CHART_SELECTION_FILL, chartBg, chartColor, dataZoomBrushStyle } from "@/utils/chartTheme";
import {
CHART_SELECTION_FILL,
chartAxisTextColor,
chartBg,
chartColor,
dataZoomBrushStyle,
} from "@/utils/chartTheme";
import { colorLuminance } from "./chartLabelContrast";
import { escapeHtml } from "@/utils/html";
import { addCommasToNumber, durationFormatter } from "@/utils/formatters";
@@ -1176,7 +1182,7 @@ export const convertStateTimelineData = (
type: "time",
min: range.start !== undefined ? zoned(range.start) : undefined,
max: range.end !== undefined ? zoned(range.end) : undefined,
axisLabel: { hideOverlap: true },
axisLabel: { hideOverlap: true, color: chartAxisTextColor() },
splitLine: {
show: config.show_gridlines !== false,
lineStyle: getGridLineStyle(store?.state?.theme),
@@ -1202,7 +1208,7 @@ export const convertStateTimelineData = (
formatter: (name: string) => displayNames.get(name) ?? name,
width: labelWidth,
overflow: "truncate",
color: chartColor("--color-text-secondary"),
color: chartAxisTextColor(),
},
},
{ type: "value", show: false, min: 0, max: 1 },
@@ -20,7 +20,7 @@ import {
TOOLTIP_SCROLL_STYLE,
} from "./shared/types";
import { getUnitValue, formatUnitValue } from "../convertDataIntoUnitValue";
import { chartColor } from "@/utils/chartTheme";
import { chartAxisTextColor, chartColor } from "@/utils/chartTheme";
import { escapeHtml } from "@/utils/html";
import {
deaccumulateHistogramSeries,
@@ -415,6 +415,7 @@ function singleHistogramOptions(
type: "category",
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: { color: chartAxisTextColor() },
},
],
yAxis: {
@@ -422,6 +423,7 @@ function singleHistogramOptions(
data: grid.bucketLabels,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
@@ -471,7 +473,7 @@ function stackedHistogramOptions(
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
// The grids share one time axis, so only the bottom one spends height on its labels.
axisLabel: { show: index === last },
axisLabel: { show: index === last, color: chartAxisTextColor() },
})),
yAxis: grids.map((grid, index) => ({
type: "category",
@@ -479,6 +481,7 @@ function stackedHistogramOptions(
data: grid.bucketLabels,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
@@ -779,6 +782,7 @@ export class HeatmapConverter implements PromQLChartConverter {
type: "category",
data: xAxisData,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: { color: chartAxisTextColor() },
},
],
yAxis: {
@@ -786,6 +790,7 @@ export class HeatmapConverter implements PromQLChartConverter {
data: seriesNames,
splitArea: HEATMAP_SPLIT_AREA,
axisLabel: {
color: chartAxisTextColor(),
overflow: "truncate",
width: config.axis_width || 150,
},
@@ -22,6 +22,7 @@ import {
buildCategoryYAxis,
buildValueAxis,
} from "./axisBuilder";
import { chartAxisTextColor } from "@/utils/chartTheme";
// Mock the convertDataIntoUnitValue module
vi.mock("../../convertDataIntoUnitValue", () => ({
@@ -532,6 +533,7 @@ describe("axisBuilder", () => {
expect(result.name).toBe("Response Time (ms)");
expect(result.nameLocation).toBe("middle");
expect(result.nameGap).toBe(50);
expect(result.nameTextStyle.color).toBe(chartAxisTextColor());
expect(result.nameTextStyle.fontWeight).toBe("bold");
expect(result.nameTextStyle.fontSize).toBe(14);
});

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