Merge remote-tracking branch 'origin/main' into feat/assistant-revamp

# Conflicts:
#	web/package-lock.json
#	web/package.json
#	web/src/components/dashboards/PanelContainer.vue
This commit is contained in:
Harsh Mahajan
2026-11-03 01:53:28 +05:30
86 changed files with 6449 additions and 383 deletions
+85 -5
View File
@@ -253,10 +253,14 @@ jobs:
done
echo "All backend checks completed successfully"
ui_unit_tests:
ui_unit_test_shards:
needs: [check_changes]
if: needs.check_changes.outputs.has_ui_changes == 'true'
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
runs-on:
labels: ubicloud-standard-8
permissions:
@@ -265,8 +269,6 @@ jobs:
steps:
- name: Clone the current repo
uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: ./.github/actions/github-auth
- name: Setup Node.js
@@ -276,14 +278,92 @@ jobs:
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Restore node_modules
id: nm
uses: actions/cache/restore@v4
with:
path: web/node_modules
key: node-modules-${{ runner.os }}-node24-${{ hashFiles('web/package-lock.json', 'web/package.json', 'web/patches/**') }}
- name: Install dependencies
if: steps.nm.outputs.cache-hit != 'true'
working-directory: web
timeout-minutes: 5
run: npm ci
- name: Run frontend unit tests with coverage and check thresholds
# Only main saves it: a PR-saved cache is visible to that PR alone and fills the 10 GB repo cache.
- name: Save node_modules
if: github.ref == 'refs/heads/main' && steps.nm.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: web/node_modules
key: node-modules-${{ runner.os }}-node24-${{ hashFiles('web/package-lock.json', 'web/package.json', 'web/patches/**') }}
- name: Run this shard with coverage
working-directory: web
run: ./coverage.sh check
env:
UI_SHARD_BALANCE: '1'
run: |
npx vitest run --coverage --shard=${{ matrix.shard }}/4 --reporter=default --reporter=blob \
--coverage.reportOnFailure --coverage.reporter=json \
--coverage.thresholds.lines=0 --coverage.thresholds.functions=0 \
--coverage.thresholds.branches=0 --coverage.thresholds.statements=0
- uses: actions/upload-artifact@v4
if: always()
with:
name: ui-unit-blob-${{ matrix.shard }}
path: web/.vitest-reports
include-hidden-files: true
retention-days: 1
compression-level: 1
# Keeps the required-check name `ui_unit_tests` while the work runs in shards.
ui_unit_tests:
needs: [check_changes, ui_unit_test_shards]
if: always() && needs.check_changes.outputs.has_ui_changes == 'true'
timeout-minutes: 20
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Require every shard to pass
run: |
[ "${{ needs.ui_unit_test_shards.result }}" = "success" ] || { echo "shards: ${{ needs.ui_unit_test_shards.result }}"; exit 1; }
- name: Clone the current repo
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: 24
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Restore node_modules
id: nm
uses: actions/cache/restore@v4
with:
path: web/node_modules
key: node-modules-${{ runner.os }}-node24-${{ hashFiles('web/package-lock.json', 'web/package.json', 'web/patches/**') }}
- name: Install dependencies
if: steps.nm.outputs.cache-hit != 'true'
working-directory: web
timeout-minutes: 5
run: npm ci
- uses: actions/download-artifact@v4
with:
pattern: ui-unit-blob-*
path: web/.vitest-reports
merge-multiple: true
- name: Merge shard coverage and check thresholds
working-directory: web
run: ./coverage.sh merge
ui_code_quality:
needs: [check_changes]
+17 -1
View File
@@ -22,6 +22,16 @@
before considering the task done. If it flags a file, fix it with
`npx prettier --write <file>` (not a full-repo `--write`) and re-run
`format:check` to confirm it's clean.
- New UI features fetch search results over the HTTP streaming endpoints, not
the one-shot ones. Logs and other SQL searches use
`POST /api/{org}/_search_stream`, or `_search_multi_stream` for several
streams. Traces use `GET /api/{org}/{stream}/traces/latest_stream`. Call them
through `useHttpStreaming` in `web/src/composables/useStreamingSearch.ts`,
which handles chunking, cancellation and the `trace_id`. Do not add new UI
callers of `_search`, `_search_multi` or `traces/latest`. Wherever a
streaming endpoint is supported for a call, the UI uses it, in existing code
as well as new code. The one-shot endpoints are for the public API and for
the few UI calls that have no streaming equivalent yet.
## Rust code organization
@@ -69,7 +79,13 @@ reworded:
## Database schema (migrations / SeaORM entities)
Applies to every NEW table and column (sqlite, MySQL and PostgreSQL alike):
Applies to every NEW table and column (SQLite and PostgreSQL alike):
- MySQL is not a supported meta store. Support was removed in #10522
(`src/infra/src/db/mysql.rs` is gone). Do not add MySQL branches, MySQL-only
migrations, MySQL feature flags or MySQL test matrices. Remaining `mysql`
mentions in the tree belong to the database monitoring feature, which watches
customers' MySQL servers, not to the meta store.
- No `json` / `jsonb` columns (sea-orm `.json()` / `.json_binary()`). JSON is
stored in a `.text()` column holding the serialized string; Rust
+8 -1
View File
@@ -20201,6 +20201,8 @@
"collapseFields": "166bd3007d204cc4",
"createAlert": "0d45260035d06818",
"custom": "494ca78f7374e46f",
"dataViewCapped": "7325c4012766274e",
"dataViewEmpty": "492230ebfdb52619",
"deletePanel": "94ef8739c0021cf6",
"deletePanelMessage": "7a2ebb2ec0d8f080",
"deletePanelTitle": "94ef8739c0021cf6",
@@ -20299,6 +20301,9 @@
"panelQueryMustHaveStream": "38e4b015c08d5ed2",
"pivotField": "b1fb54fe642eea35",
"pivotFieldTooltip": "2899dc3dad2edcdf",
"previewChart": "3e5b90ae053ff3e7",
"previewData": "cec3a9b89b2e3913",
"previewView": "bba9db4d6467fb35",
"promQL": "b480c1c74e687b1a",
"queryInspector": "137ea027e182fe16",
"refresh": "0e91610117029a62",
@@ -20311,6 +20316,8 @@
"rowFields": "849ea764f1d166cc",
"save": "1509f561f2416598",
"saveAndStay": "36cde213a1f9122d",
"showChart": "6d64a1a5dcbf43c4",
"showRawData": "84b93a056a49f909",
"source": "0e570ca6fabe24f9",
"sunburstLevelsHint": "d1c3b001a0b680e2",
"target": "978354db0c00fc78",
@@ -20760,7 +20767,7 @@
"search": "e0e8a54761ea47d2",
"searchHistory": "98bb2ed79442e418",
"selectColumn": "91db086469f6b553",
"selectExternalDestination": "bf60152355f4409e",
"selectExternalDestination": "3a0217058ee6517f",
"selectStream": "b7a2b0a66ccc9073",
"selectValidTimeRange": "1caf1805174e58d7",
"separatorRequiredStringseparated": "9be4ec7cb09e1530",
@@ -280,17 +280,21 @@ const MCP_OPT_OUT_CATEGORIES: [&str; 2] = ["authorization", "users"];
openobserve_api_management::request::dashboards::reports_history::get_report_history,
openobserve_api_management::request::authz::fga::create_role,
openobserve_api_management::request::authz::fga::delete_role,
openobserve_api_management::request::authz::fga::delete_role_bulk,
openobserve_api_management::request::authz::fga::get_roles,
openobserve_api_management::request::authz::fga::update_role,
openobserve_api_management::request::authz::fga::get_role_permissions,
openobserve_api_management::request::authz::fga::get_all_role_permissions,
openobserve_api_management::request::authz::fga::get_roles_for_user,
openobserve_api_management::request::authz::fga::get_roles_for_all_users,
openobserve_api_management::request::authz::fga::get_users_with_role,
openobserve_api_management::request::authz::fga::create_group,
openobserve_api_management::request::authz::fga::update_group,
openobserve_api_management::request::authz::fga::get_groups_for_user,
openobserve_api_management::request::authz::fga::get_groups,
openobserve_api_management::request::authz::fga::get_group_details,
openobserve_api_management::request::authz::fga::delete_group,
openobserve_api_management::request::authz::fga::delete_group_bulk,
keys::save,
keys::get,
keys::list,
@@ -2031,6 +2035,26 @@ mod tests {
);
}
#[test]
fn fga_bulk_deletes_and_per_user_lookups_are_published() {
let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
for (path, method) in [
("/api/{org_id}/roles/bulk", "delete"),
("/api/{org_id}/groups/bulk", "delete"),
("/api/{org_id}/users/{user_email}/roles", "get"),
("/api/{org_id}/users/{user_email}/groups", "get"),
] {
let op = &spec["paths"][path][method];
assert!(op.is_object(), "{method} {path} missing");
// OSS stubs answer 403 before any validation, so only enterprise can return 400.
assert_eq!(
op["responses"]["400"].is_object(),
cfg!(feature = "enterprise"),
"{method} {path}"
);
}
}
#[test]
fn fleet_endpoints_are_documented_and_the_removed_ones_are_not() {
let spec = serde_json::to_value(ApiDoc::openapi()).unwrap();
+374 -89
View File
@@ -48,6 +48,7 @@ use crate::{
request_body(content = inline(UserRoleRequest), description = "UserRoleRequest", content_type = "application/json"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Invalid, standard or already existing role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -68,6 +69,14 @@ pub async fn create_role(
if role_name.is_empty() || is_standard_role(&role_name) {
return MetaHttpResponse::bad_request("Custom role name cannot be empty or standard role");
}
// A name the role object accepts can still overflow the `#has` role user.
if o2_openfga::authorizer::org_scoped_object("role", &org_id, &role_name).is_none()
|| !o2_openfga::authorizer::is_valid_tuple_user(
&o2_openfga::authorizer::roles::format_role_user(&org_id, &role_name),
)
{
return MetaHttpResponse::bad_request("Invalid role name");
}
match o2_openfga::authorizer::roles::create_role(&role_name, &org_id).await {
Ok(_) => MetaHttpResponse::ok("Role created successfully"),
@@ -132,6 +141,7 @@ pub async fn create_role(
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Invalid role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -140,9 +150,12 @@ pub async fn create_role(
)
)]
pub async fn delete_role(Path((org_id, role_name)): Path<(String, String)>) -> Response {
if !is_deletable_role(&org_id, &role_name) {
return MetaHttpResponse::bad_request("Invalid role name");
}
match o2_openfga::authorizer::roles::delete_role(&org_id, &role_name).await {
Ok(_) => MetaHttpResponse::ok(serde_json::json!({"successful": "true"})),
Err(err) => MetaHttpResponse::internal_error(err),
Err(e) => failure_response(&e, "Failed to delete role"),
}
}
@@ -165,6 +178,7 @@ pub async fn delete_role(Path((org_id, role_name)): Path<(String, String)>) -> R
request_body(content = BulkDeleteRequest, description = "names of role to be deleted", content_type = "application/json"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = BulkDeleteResponse),
(status = 400, description = "Invalid role name", content_type = "application/json", body = ()),
),
extensions(
("x-o2-ratelimit" = json!({"module": "Roles", "operation": "delete"})),
@@ -176,6 +190,13 @@ pub async fn delete_role_bulk(
Headers(user_email): Headers<UserEmail>,
Json(req): Json<BulkDeleteRequest>,
) -> Response {
if let Some(name) = req
.ids
.iter()
.find(|name| !is_deletable_role(&org_id, name))
{
return MetaHttpResponse::bad_request(format!("Invalid role name: {name}"));
}
let user_id = user_email.user_id;
for name in &req.ids {
@@ -196,27 +217,15 @@ pub async fn delete_role_bulk(
}
}
let mut successful = Vec::with_capacity(req.ids.len());
let mut unsuccessful = Vec::with_capacity(req.ids.len());
let mut err = None;
for name in req.ids {
match o2_openfga::authorizer::roles::delete_role(&org_id, &name).await {
Ok(_) => {
successful.push(name);
}
Err(e) => {
log::error!("error in deleting role {org_id}/{name} : {e}");
unsuccessful.push(name);
err = Some(e.to_string());
}
}
}
MetaHttpResponse::json(BulkDeleteResponse {
successful,
unsuccessful,
err,
})
let org = org_id.as_str();
MetaHttpResponse::json(
bulk_delete(
req.ids,
|name| async move { o2_openfga::authorizer::roles::delete_role(org, &name).await },
"Failed to delete role",
)
.await,
)
}
#[cfg(not(feature = "enterprise"))]
@@ -343,10 +352,10 @@ pub async fn get_roles(
}
}
match o2_openfga::authorizer::roles::get_all_roles(&org_id, permitted).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
}
lookup_response(
o2_openfga::authorizer::roles::get_all_roles(&org_id, permitted).await,
"Failed to list roles",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -429,7 +438,7 @@ pub async fn update_role(
.await
{
Ok(_) => MetaHttpResponse::ok("Role updated successfully"),
Err(err) => MetaHttpResponse::internal_error(err),
Err(e) => failure_response(&e, "Failed to update role"),
}
}
@@ -481,16 +490,20 @@ pub async fn update_role(
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(Vec<Object>)),
(status = 400, description = "Invalid role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_role_permissions(
Path((org_id, role_id, resource)): Path<(String, String, String)>,
) -> Response {
match o2_openfga::authorizer::roles::get_role_permissions(&org_id, &role_id, &resource).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
if !is_storable_name("role", &org_id, &role_id) {
return MetaHttpResponse::bad_request("Invalid role name");
}
lookup_response(
o2_openfga::authorizer::roles::get_role_permissions(&org_id, &role_id, &resource).await,
"Failed to get role permissions",
)
}
#[cfg(feature = "enterprise")]
@@ -512,14 +525,18 @@ pub async fn get_role_permissions(
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(Vec<Object>)),
(status = 400, description = "Invalid role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_all_role_permissions(Path((org_id, role_id)): Path<(String, String)>) -> Response {
match o2_openfga::authorizer::roles::get_all_role_permissions(&org_id, &role_id).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
if !is_storable_name("role", &org_id, &role_id) {
return MetaHttpResponse::bad_request("Invalid role name");
}
lookup_response(
o2_openfga::authorizer::roles::get_all_role_permissions(&org_id, &role_id).await,
"Failed to get role permissions",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -592,11 +609,15 @@ pub async fn get_role_permissions(Path(_path): Path<(String, String, String)>) -
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(Vec<String>)),
(status = 400, description = "Invalid role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_users_with_role(Path((org_id, role_id)): Path<(String, String)>) -> Response {
role_lookup_response(
if !is_storable_name("role", &org_id, &role_id) {
return MetaHttpResponse::bad_request("Invalid role name");
}
lookup_response(
o2_openfga::authorizer::roles::get_users_with_role(&org_id, &role_id).await,
"Failed to get users with role",
)
@@ -646,11 +667,15 @@ pub async fn get_users_with_role(Path(_path): Path<(String, String)>) -> Respons
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(Vec<String>)),
(status = 400, description = "Invalid user id", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_roles_for_user(Path((org_id, user_email)): Path<(String, String)>) -> Response {
role_lookup_response(
if !o2_openfga::authorizer::is_valid_user_id(&user_email) {
return MetaHttpResponse::bad_request("Invalid user id");
}
lookup_response(
o2_openfga::authorizer::roles::get_roles_for_org_user(&org_id, &user_email).await,
"Failed to get roles for user",
)
@@ -703,10 +728,10 @@ pub async fn get_roles_for_user(Path(_path): Path<(String, String)>) -> Response
)
)]
pub async fn get_roles_for_all_users(Path(org_id): Path<String>) -> Response {
match o2_openfga::authorizer::roles::get_roles_for_all_org_users(&org_id).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
}
lookup_response(
o2_openfga::authorizer::roles::get_roles_for_all_org_users(&org_id).await,
"Failed to get roles for users",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -752,14 +777,18 @@ pub async fn get_roles_for_all_users(Path(_path): Path<String>) -> Response {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(Vec<String>)),
(status = 400, description = "Invalid user id", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_groups_for_user(Path((org_id, user_email)): Path<(String, String)>) -> Response {
match o2_openfga::authorizer::groups::get_groups_for_org_user(&org_id, &user_email).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
if !o2_openfga::authorizer::is_valid_user_id(&user_email) {
return MetaHttpResponse::bad_request("Invalid user id");
}
lookup_response(
o2_openfga::authorizer::groups::get_groups_for_org_user(&org_id, &user_email).await,
"Failed to get groups for user",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -832,8 +861,7 @@ pub async fn create_group(
match o2_openfga::authorizer::groups::create_group(&org_id, &user_grp.name, users).await {
Ok(_) => MetaHttpResponse::ok("Group created successfully"),
// The authorizer's error carries raw OpenFGA text, which stays in the server log.
Err(_) => MetaHttpResponse::internal_error("Failed to create group"),
Err(e) => failure_response(&e, "Failed to create group"),
}
}
@@ -885,7 +913,7 @@ pub async fn create_group(
request_body(content = inline(UserGroupRequest), description = "UserGroupRequest", content_type = "application/json"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Invalid group name or user id", content_type = "application/json", body = ()),
(status = 400, description = "Invalid group name, user id or role name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
@@ -907,6 +935,15 @@ pub async fn update_group(
{
return MetaHttpResponse::bad_request("Invalid user id in group users");
}
if !user_grp
.add_roles
.iter()
.chain(user_grp.remove_roles.iter())
.flatten()
.all(|role| o2_openfga::authorizer::org_scoped_object("role", &org_id, role).is_some())
{
return MetaHttpResponse::bad_request("Invalid role name in group roles");
}
match o2_openfga::authorizer::groups::update_group(
&org_id,
@@ -919,8 +956,7 @@ pub async fn update_group(
.await
{
Ok(_) => MetaHttpResponse::ok("Group updated successfully"),
// The authorizer's error carries raw OpenFGA text, which stays in the server log.
Err(_) => MetaHttpResponse::internal_error("Failed to update group"),
Err(e) => failure_response(&e, "Failed to update group"),
}
}
@@ -1012,10 +1048,10 @@ pub async fn get_groups(
permitted = Some(local_permitted);
}
match o2_openfga::authorizer::groups::get_all_groups(&org_id, permitted).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
}
lookup_response(
o2_openfga::authorizer::groups::get_all_groups(&org_id, permitted).await,
"Failed to list groups",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -1061,14 +1097,18 @@ pub async fn get_groups(Path(_path): Path<String>) -> Response {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = inline(UserGroup)),
(status = 400, description = "Invalid group name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn get_group_details(Path((org_id, group_name)): Path<(String, String)>) -> Response {
match o2_openfga::authorizer::groups::get_group_details(&org_id, &group_name).await {
Ok(res) => Json(res).into_response(),
Err(err) => MetaHttpResponse::internal_error(err),
if !is_storable_name("group", &org_id, &group_name) {
return MetaHttpResponse::bad_request("Invalid group name");
}
lookup_response(
o2_openfga::authorizer::groups::get_group_details(&org_id, &group_name).await,
"Failed to get group",
)
}
#[cfg(not(feature = "enterprise"))]
@@ -1185,13 +1225,17 @@ pub async fn get_resources(Path(_org_id): Path<String>) -> Response {
),
responses(
(status = 200, description = "Success", content_type = "application/json", body = Object),
(status = 400, description = "Invalid group name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
)
)]
pub async fn delete_group(Path((org_id, group_name)): Path<(String, String)>) -> Response {
if !is_storable_name("group", &org_id, &group_name) {
return MetaHttpResponse::bad_request("Invalid group name");
}
match o2_openfga::authorizer::groups::delete_group(&org_id, &group_name).await {
Ok(_) => MetaHttpResponse::ok(serde_json::json!({"successful": "true"})),
Err(err) => MetaHttpResponse::internal_error(err),
Err(e) => failure_response(&e, "Failed to delete group"),
}
}
@@ -1214,6 +1258,7 @@ pub async fn delete_group(Path((org_id, group_name)): Path<(String, String)>) ->
request_body(content = BulkDeleteRequest, description = "user group names", content_type = "application/json"),
responses(
(status = 200, description = "Success", content_type = "application/json", body = BulkDeleteResponse),
(status = 400, description = "Invalid group name", content_type = "application/json", body = ()),
(status = 500, description = "Failure", content_type = "application/json", body = ()),
),
extensions(
@@ -1225,6 +1270,13 @@ pub async fn delete_group_bulk(
Headers(user_email): Headers<UserEmail>,
Json(req): Json<BulkDeleteRequest>,
) -> Response {
if let Some(name) = req
.ids
.iter()
.find(|name| !is_storable_name("group", &org_id, name))
{
return MetaHttpResponse::bad_request(format!("Invalid group name: {name}"));
}
let user_id = user_email.user_id;
for name in &req.ids {
@@ -1245,28 +1297,15 @@ pub async fn delete_group_bulk(
}
}
let mut successful = Vec::with_capacity(req.ids.len());
let mut unsuccessful = Vec::with_capacity(req.ids.len());
let mut err = None;
for name in req.ids {
match o2_openfga::authorizer::groups::delete_group(&org_id, &name).await {
Ok(_) => {
successful.push(name);
}
Err(e) => {
log::error!("error in deleting group {org_id}/{name} : {e}");
unsuccessful.push(name);
err = Some(e.to_string());
}
}
}
MetaHttpResponse::json(BulkDeleteResponse {
successful,
unsuccessful,
err,
})
let org = org_id.as_str();
MetaHttpResponse::json(
bulk_delete(
req.ids,
|name| async move { o2_openfga::authorizer::groups::delete_group(org, &name).await },
"Failed to delete group",
)
.await,
)
}
#[cfg(not(feature = "enterprise"))]
@@ -1343,16 +1382,63 @@ fn hide_slack_unless_enabled<'a, 'b>(
.collect()
}
/// The lookup's list, or a fixed 500 that keeps the OpenFGA error in the server log.
/// The lookup's result, or a fixed 500 that keeps the OpenFGA error in the server log.
#[cfg(feature = "enterprise")]
fn role_lookup_response(result: Result<Vec<String>, anyhow::Error>, failure: &str) -> Response {
fn lookup_response<T: serde::Serialize>(
result: Result<T, anyhow::Error>,
failure: &str,
) -> Response {
match result {
Ok(list) => Json(list).into_response(),
Err(e) => {
log::error!("{failure}: {e}");
MetaHttpResponse::internal_error(failure)
Ok(found) => Json(found).into_response(),
Err(e) => failure_response(&e, failure),
}
}
#[cfg(feature = "enterprise")]
fn failure_response(e: &anyhow::Error, failure: &str) -> Response {
// The authorizer's error carries raw OpenFGA text, which stays in the server log.
log::error!("{failure}: {e}");
MetaHttpResponse::internal_error(failure)
}
#[cfg(feature = "enterprise")]
fn is_storable_name(object_type: &str, org_id: &str, name: &str) -> bool {
// Shape only: an empty name was storable before, so such a role or group must stay deletable.
o2_openfga::authorizer::is_valid_tuple_object(&format!("{object_type}:{org_id}/{name}"))
}
#[cfg(feature = "enterprise")]
fn is_deletable_role(org_id: &str, name: &str) -> bool {
// Deleting reads the `#has` role user back; an overlong one fails every read.
is_storable_name("role", org_id, name)
&& o2_openfga::authorizer::is_valid_tuple_user(
&o2_openfga::authorizer::roles::format_role_user(org_id, name),
)
}
#[cfg(feature = "enterprise")]
async fn bulk_delete<F, Fut>(ids: Vec<String>, delete: F, failure: &str) -> BulkDeleteResponse
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<(), anyhow::Error>>,
{
let mut successful = Vec::with_capacity(ids.len());
let mut unsuccessful = Vec::with_capacity(ids.len());
for name in ids {
match delete(name.clone()).await {
Ok(_) => successful.push(name),
Err(e) => {
log::error!("{failure} {name}: {e}");
unsuccessful.push(name);
}
}
}
let err = (!unsuccessful.is_empty()).then(|| format!("{failure}: {}", unsuccessful.join(", ")));
BulkDeleteResponse {
successful,
unsuccessful,
err,
}
}
#[cfg(test)]
@@ -1456,6 +1542,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to delete role"
);
}
#[cfg(not(feature = "enterprise"))]
@@ -1475,6 +1565,7 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(response_json(resp).await["message"], "Failed to list roles");
}
#[cfg(not(feature = "enterprise"))]
@@ -1509,6 +1600,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to update role"
);
// A type the model lacks is the client's error, not OpenFGA's 400 surfaced as a 500.
let resp = update_role(
@@ -1576,7 +1671,7 @@ mod tests {
init_ofga_test();
let resp =
get_users_with_role(Path(("test_org".to_string(), "test_role".to_string()))).await;
// The failing-read 500 is covered by `role_lookup_response_hides_the_openfga_error`.
// The failing-read 500 is covered by `lookup_response_hides_the_openfga_error`.
assert!(
!o2_openfga::config::get_config().enabled,
"unit tests run without OpenFGA"
@@ -1603,7 +1698,7 @@ mod tests {
"test@example.com".to_string(),
)))
.await;
// The failing-read 500 is covered by `role_lookup_response_hides_the_openfga_error`.
// The failing-read 500 is covered by `lookup_response_hides_the_openfga_error`.
assert!(
!o2_openfga::config::get_config().enabled,
"unit tests run without OpenFGA"
@@ -1625,9 +1720,9 @@ mod tests {
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn role_lookup_response_hides_the_openfga_error() {
let failed = role_lookup_response(
Err(anyhow::anyhow!("raw OpenFGA text")),
async fn lookup_response_hides_the_openfga_error() {
let failed = lookup_response(
Err::<Vec<String>, _>(anyhow::anyhow!("raw OpenFGA text")),
"Failed to get roles for user",
);
assert_eq!(
@@ -1638,11 +1733,167 @@ mod tests {
response_json(failed).await["message"],
"Failed to get roles for user"
);
let found = role_lookup_response(Ok(vec!["editor".to_string()]), "unused");
let found = lookup_response(Ok(vec!["editor".to_string()]), "unused");
assert_eq!(extract_status_code(&found), StatusCode::OK);
assert_eq!(response_json(found).await, serde_json::json!(["editor"]));
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn roles_and_groups_reject_unstorable_input_with_400() {
init_ofga_test();
let path = |name: &str| Path(("test_org".to_string(), name.to_string()));
let bulk = |name: &str| BulkDeleteRequest {
ids: vec!["fine".to_string(), name.to_string()],
};
// Fits the `role:` object but not the `#has` role user a delete reads back.
let too_long = "r".repeat(240);
assert!(is_storable_name("role", "test_org", &too_long));
for resp in [
delete_role(path(&too_long)).await,
delete_role_bulk(
Path("test_org".to_string()),
mock_user_email(),
Json(bulk(&too_long)),
)
.await,
] {
assert_eq!(extract_status_code(&resp), StatusCode::BAD_REQUEST);
}
for name in ["a#b", "a b", "a:b"] {
let responses = [
delete_role(path(name)).await,
delete_group(path(name)).await,
get_group_details(path(name)).await,
get_users_with_role(path(name)).await,
get_all_role_permissions(path(name)).await,
get_role_permissions(Path((
"test_org".to_string(),
name.to_string(),
"stream".to_string(),
)))
.await,
delete_role_bulk(
Path("test_org".to_string()),
mock_user_email(),
Json(bulk(name)),
)
.await,
delete_group_bulk(
Path("test_org".to_string()),
mock_user_email(),
Json(bulk(name)),
)
.await,
];
for resp in responses {
assert_eq!(
extract_status_code(&resp),
StatusCode::BAD_REQUEST,
"{name:?}"
);
}
let roles = delete_role_bulk(
Path("test_org".to_string()),
mock_user_email(),
Json(bulk(name)),
)
.await;
assert_eq!(
response_json(roles).await["message"],
format!("Invalid role name: {name}")
);
let groups = delete_group_bulk(
Path("test_org".to_string()),
mock_user_email(),
Json(bulk(name)),
)
.await;
assert_eq!(
response_json(groups).await["message"],
format!("Invalid group name: {name}")
);
}
for user in ["*", "a#b", ""] {
for resp in [
get_groups_for_user(path(user)).await,
get_roles_for_user(path(user)).await,
] {
assert_eq!(
extract_status_code(&resp),
StatusCode::BAD_REQUEST,
"{user:?}"
);
}
}
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn update_role_rejects_invalid_input_with_400() {
init_ofga_test();
let members = |user: &str| Some(HashSet::from([user.to_string()]));
// Fits the 256-char `role:` object but not the `#has` role-user form.
let too_long_for_role_user = "r".repeat(240);
assert!(
o2_openfga::authorizer::org_scoped_object("role", "test_org", &too_long_for_role_user)
.is_some()
);
for (name, add_users, remove_users) in [
("test_role", members("*"), None),
("test_role", None, members("a#b")),
("a#b", None, None),
("", members("user1"), None),
(too_long_for_role_user.as_str(), None, None),
] {
let role_request = o2_dex::meta::auth::RoleRequest {
add: vec![],
remove: vec![],
add_users,
remove_users,
};
let resp = update_role(
Path(("test_org".to_string(), name.to_string())),
Json(role_request),
)
.await;
assert_eq!(
extract_status_code(&resp),
StatusCode::BAD_REQUEST,
"{name:?}"
);
}
let resp = create_role(
Path("test_org".to_string()),
Json(UserRoleRequest {
role: too_long_for_role_user,
custom: None,
}),
)
.await;
assert_eq!(response_json(resp).await["message"], "Invalid role name");
}
#[cfg(feature = "enterprise")]
#[tokio::test]
async fn bulk_delete_hides_the_openfga_error() {
let resp = bulk_delete(
vec!["kept".to_string(), "broken".to_string()],
|name| async move {
if name == "broken" {
Err(anyhow::anyhow!("raw OpenFGA text"))
} else {
Ok(())
}
},
"Failed to delete role",
)
.await;
assert_eq!(resp.successful, vec!["kept".to_string()]);
assert_eq!(resp.unsuccessful, vec!["broken".to_string()]);
assert_eq!(resp.err.as_deref(), Some("Failed to delete role: broken"));
}
#[tokio::test]
async fn test_get_roles_for_all_users_enterprise() {
#[cfg(feature = "enterprise")]
@@ -1653,6 +1904,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to get roles for users"
);
}
#[cfg(not(feature = "enterprise"))]
@@ -1676,6 +1931,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to get groups for user"
);
}
#[cfg(not(feature = "enterprise"))]
@@ -1828,6 +2087,23 @@ mod tests {
"{name:?}"
);
}
for (add_roles, remove_roles) in [(members("a#b"), None), (None, members(""))] {
let group_request = UserGroupRequest {
add_users: None,
remove_users: None,
add_roles,
remove_roles,
};
let resp = update_group(
Path(("test_org".to_string(), "test_group".to_string())),
Json(group_request),
)
.await;
assert_eq!(
response_json(resp).await["message"],
"Invalid role name in group roles"
);
}
}
#[tokio::test]
@@ -1840,6 +2116,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to list groups"
);
}
#[cfg(not(feature = "enterprise"))]
@@ -1860,6 +2140,7 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(response_json(resp).await["message"], "Failed to get group");
}
#[cfg(not(feature = "enterprise"))]
@@ -1880,6 +2161,10 @@ mod tests {
extract_status_code(&resp),
StatusCode::INTERNAL_SERVER_ERROR
);
assert_eq!(
response_json(resp).await["message"],
"Failed to delete group"
);
}
#[cfg(not(feature = "enterprise"))]
+6 -1
View File
@@ -119,7 +119,12 @@ impl FirstDataIo for LiveIo {
stream_name: &str,
) -> Vec<Option<i64>> {
let mut created = Vec::new();
for kind in [StreamType::Logs, StreamType::Metrics, StreamType::Traces] {
for kind in [
StreamType::Logs,
StreamType::Metrics,
StreamType::Traces,
StreamType::Profiles,
] {
for name in db::schema::list_streams_from_cache(org_id, kind).await {
if !is_user_data_stream(kind, &name) || (kind == stream_type && name == stream_name)
{
+1
View File
@@ -430,6 +430,7 @@ async fn buffer_records(
Authz::new(stream_name),
)
.await;
crate::onboarding::on_user_stream_created(org_id, StreamType::Profiles, stream_name);
}
stream_schema_map.insert(stream_name.to_string(), SchemaCache::new(schema));
}
+8 -2
View File
@@ -410,7 +410,10 @@ pub async fn update_workflow(workflow: Workflow) -> Result<(), anyhow::Error> {
pub async fn update_draft(workflow: Workflow) -> Result<(), anyhow::Error> {
reject_unsupported_nodes(&workflow)?;
db::workflows::update_draft_record(workflow.clone()).await?;
// a 0-row update must not be replayed: peers would insert it as a new draft
if db::workflows::update_draft_record(&workflow.org_id, workflow.clone()).await? == 0 {
return Ok(());
}
db::workflows::notify_draft_upsert(&workflow).await?;
Ok(())
}
@@ -634,7 +637,10 @@ pub async fn delete_workflow(org_id: &str, id: &str) -> Result<(), anyhow::Error
}
pub async fn delete_draft(org_id: &str, id: &str) -> Result<(), anyhow::Error> {
db::workflows::delete_draft_record(id).await?;
// a 0-row delete must not drop the owner's tuple or replay unscoped to peers
if db::workflows::delete_draft_record(org_id, id).await? == 0 {
return Ok(());
}
remove_ownership(org_id, "workflows", Authz::new(id)).await;
db::workflows::notify_draft_delete(id).await?;
Ok(())
+4 -6
View File
@@ -252,9 +252,8 @@ pub async fn update_workflow_record(workflow: Workflow) -> Result<(), anyhow::Er
Ok(())
}
pub async fn update_draft_record(workflow: Workflow) -> Result<(), anyhow::Error> {
infra::table::workflows::update_draft(workflow).await?;
Ok(())
pub async fn update_draft_record(org_id: &str, workflow: Workflow) -> Result<u64, anyhow::Error> {
infra::table::workflows::update_draft(org_id, workflow).await
}
pub async fn promote_draft(org_id: &str, workflow: Workflow) -> Result<(), anyhow::Error> {
@@ -267,9 +266,8 @@ pub async fn delete_workflow_record(id: &str) -> Result<(), anyhow::Error> {
Ok(())
}
pub async fn delete_draft_record(id: &str) -> Result<(), anyhow::Error> {
infra::table::workflows::delete_draft(id).await?;
Ok(())
pub async fn delete_draft_record(org_id: &str, id: &str) -> Result<u64, anyhow::Error> {
infra::table::workflows::delete_draft(org_id, id).await
}
pub async fn notify_workflow_upsert(workflow: &Workflow) -> Result<(), anyhow::Error> {
+6 -3
View File
@@ -198,7 +198,7 @@ pub fn remove_latest(map: &mut hashbrown::HashMap<String, SchemaCache>, key: &st
remove_counted(map, &ORG_USER_STREAMS, key);
}
/// Whether the org has a logs, metrics or traces stream outside the internal ones; O(1).
/// Whether the org has a logs, metrics, traces or profiles stream outside the internal ones; O(1).
pub fn org_has_user_stream(org_id: &str) -> bool {
ORG_USER_STREAMS.contains_key(org_id)
}
@@ -206,7 +206,7 @@ pub fn org_has_user_stream(org_id: &str) -> bool {
pub fn is_user_data_stream(stream_type: StreamType, stream_name: &str) -> bool {
matches!(
stream_type,
StreamType::Logs | StreamType::Metrics | StreamType::Traces
StreamType::Logs | StreamType::Metrics | StreamType::Traces | StreamType::Profiles
) && !INTERNAL_STREAM_NAMES.contains(&stream_name)
&& !stream_name.starts_with("_o2_")
}
@@ -1140,6 +1140,7 @@ fn user_data_org(key: &str) -> Option<&str> {
"logs" => StreamType::Logs,
"metrics" => StreamType::Metrics,
"traces" => StreamType::Traces,
"profiles" => StreamType::Profiles,
_ => return None,
};
is_user_data_stream(stream_type, stream_name).then_some(org_id)
@@ -1946,16 +1947,18 @@ mod tests {
}
assert!(index.is_empty());
assert!(is_user_data_stream(StreamType::Metrics, "cpu_usage"));
assert!(is_user_data_stream(StreamType::Profiles, "default"));
assert!(!is_user_data_stream(StreamType::Logs, "usage"));
assert!(!is_user_data_stream(StreamType::EnrichmentTables, "app"));
assert_eq!(user_data_org("acme/metrics/up/with/slash"), Some("acme"));
assert_eq!(user_data_org("acme/profiles/default"), Some("acme"));
}
#[test]
fn test_user_stream_index_equals_a_recount_after_10k_random_inserts_and_removes() {
let mut map = hashbrown::HashMap::new();
let index = RwHashMap::default();
let types = ["logs", "metrics", "traces", "metadata"];
let types = ["logs", "metrics", "traces", "profiles", "metadata"];
let names = ["app", "usage", "_o2_x", "default", "cpu"];
let mut seed: u64 = 0x5eed;
let mut next = || {
+185 -27
View File
@@ -15,8 +15,8 @@
use config::meta::pipeline::components::{Edge, Node};
use sea_orm::{
ColumnTrait, Condition, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect, Set,
TransactionTrait, prelude::Expr, sea_query::Func,
ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, QuerySelect,
Set, TransactionTrait, prelude::Expr, sea_query::Func,
};
use serde::{Deserialize, Serialize};
@@ -794,34 +794,19 @@ pub async fn save_draft(draft: Workflow) -> Result<(), anyhow::Error> {
Ok(())
}
pub async fn update_draft(draft: Workflow) -> Result<(), anyhow::Error> {
let client = get_orm_client_rw().await;
let now = chrono::Utc::now().timestamp_millis();
let model = workflow_drafts::ActiveModel {
updated_at: Set(now),
name: Set(draft.name),
description: Set(draft.description),
nodes: Set(serde_json::to_string(&draft.nodes)?),
edges: Set(serde_json::to_string(&draft.edges)?),
..Default::default()
};
workflow_drafts::Entity::update_many()
.filter(workflow_drafts::Column::Id.eq(draft.id))
.set(model)
.exec(client)
.await?;
Ok(())
/// Returns the number of drafts updated, 0 when `org_id` does not own the draft.
pub async fn update_draft(org_id: &str, draft: Workflow) -> Result<u64, anyhow::Error> {
update_draft_in(get_orm_client_rw().await, org_id, draft).await
}
pub async fn delete_draft(id: &str) -> Result<(), anyhow::Error> {
let client = get_orm_client_rw().await;
/// Returns the number of drafts deleted, 0 when `org_id` does not own the draft.
pub async fn delete_draft(org_id: &str, id: &str) -> Result<u64, anyhow::Error> {
delete_draft_in(get_orm_client_rw().await, org_id, id).await
}
workflow_drafts::Entity::delete_many()
.filter(workflow_drafts::Column::Id.eq(id))
.exec(client)
.await?;
Ok(())
/// Org that owns draft `id`, for the super-cluster replay whose delete message carries no org.
pub async fn get_draft_org_id(id: &str) -> Result<Option<String>, anyhow::Error> {
get_draft_org_id_in(get_orm_client_ro().await, id).await
}
pub async fn delete_drafts_by_org(org_id: &str) -> Result<(), anyhow::Error> {
@@ -889,3 +874,176 @@ pub async fn promote_draft_to_workflow(org_id: &str, draft: Workflow) -> Result<
Ok(())
}
async fn update_draft_in<C: ConnectionTrait>(
conn: &C,
org_id: &str,
draft: Workflow,
) -> Result<u64, anyhow::Error> {
let now = chrono::Utc::now().timestamp_millis();
let model = workflow_drafts::ActiveModel {
updated_at: Set(now),
name: Set(draft.name),
description: Set(draft.description),
nodes: Set(serde_json::to_string(&draft.nodes)?),
edges: Set(serde_json::to_string(&draft.edges)?),
..Default::default()
};
let res = workflow_drafts::Entity::update_many()
.filter(workflow_drafts::Column::OrgId.eq(org_id))
.filter(workflow_drafts::Column::Id.eq(draft.id))
.set(model)
.exec(conn)
.await?;
Ok(res.rows_affected)
}
async fn delete_draft_in<C: ConnectionTrait>(
conn: &C,
org_id: &str,
id: &str,
) -> Result<u64, anyhow::Error> {
let res = workflow_drafts::Entity::delete_many()
.filter(workflow_drafts::Column::OrgId.eq(org_id))
.filter(workflow_drafts::Column::Id.eq(id))
.exec(conn)
.await?;
Ok(res.rows_affected)
}
async fn get_draft_org_id_in<C: ConnectionTrait>(
conn: &C,
id: &str,
) -> Result<Option<String>, anyhow::Error> {
let res = workflow_drafts::Entity::find()
.select_only()
.column(workflow_drafts::Column::OrgId)
.filter(workflow_drafts::Column::Id.eq(id))
.into_tuple::<String>()
.one(conn)
.await?;
Ok(res)
}
#[cfg(test)]
mod tests {
use sea_orm::{ConnectOptions, Database, DatabaseConnection, Schema};
use super::*;
const DRAFT_ID: &str = "draft-1";
const OWNER_ORG: &str = "owner";
const OTHER_ORG: &str = "other";
async fn db_with_draft() -> DatabaseConnection {
let mut opts = ConnectOptions::new("sqlite::memory:".to_string());
opts.max_connections(1);
let db = Database::connect(opts).await.unwrap();
let backend = db.get_database_backend();
let schema = Schema::new(backend);
db.execute(backend.build(&schema.create_table_from_entity(workflow_drafts::Entity)))
.await
.unwrap();
workflow_drafts::Entity::insert(workflow_drafts::ActiveModel {
id: Set(DRAFT_ID.to_string()),
org_id: Set(OWNER_ORG.to_string()),
folder_id: Set("folder".to_string()),
created_at: Set(1),
updated_at: Set(1),
created_by: Set("user".to_string()),
name: Set("original".to_string()),
description: Set("original".to_string()),
nodes: Set("[]".to_string()),
edges: Set("[]".to_string()),
})
.exec(&db)
.await
.unwrap();
db
}
async fn stored_draft(db: &DatabaseConnection) -> Option<workflow_drafts::Model> {
workflow_drafts::Entity::find_by_id(DRAFT_ID)
.one(db)
.await
.unwrap()
}
fn renamed_draft(org_id: &str) -> Workflow {
Workflow {
id: DRAFT_ID.to_string(),
org_id: org_id.to_string(),
folder_id: "folder".to_string(),
created_at: 1,
updated_at: 1,
created_by: "user".to_string(),
enabled: true,
name: "renamed".to_string(),
description: "renamed".to_string(),
nodes: vec![],
edges: vec![],
}
}
#[tokio::test]
async fn update_draft_from_another_org_changes_nothing() {
let db = db_with_draft().await;
let updated = update_draft_in(&db, OTHER_ORG, renamed_draft(OTHER_ORG))
.await
.unwrap();
assert_eq!(updated, 0);
assert_eq!(stored_draft(&db).await.unwrap().name, "original");
}
#[tokio::test]
async fn update_draft_from_owning_org_applies() {
let db = db_with_draft().await;
let updated = update_draft_in(&db, OWNER_ORG, renamed_draft(OWNER_ORG))
.await
.unwrap();
assert_eq!(updated, 1);
assert_eq!(stored_draft(&db).await.unwrap().name, "renamed");
}
#[tokio::test]
async fn delete_draft_from_another_org_removes_nothing() {
let db = db_with_draft().await;
let deleted = delete_draft_in(&db, OTHER_ORG, DRAFT_ID).await.unwrap();
assert_eq!(deleted, 0);
assert!(stored_draft(&db).await.is_some());
}
#[tokio::test]
async fn delete_draft_from_owning_org_removes_it() {
let db = db_with_draft().await;
let deleted = delete_draft_in(&db, OWNER_ORG, DRAFT_ID).await.unwrap();
assert_eq!(deleted, 1);
assert!(stored_draft(&db).await.is_none());
}
#[tokio::test]
async fn get_draft_org_id_returns_the_owning_org() {
let db = db_with_draft().await;
let org = get_draft_org_id_in(&db, DRAFT_ID).await.unwrap();
assert_eq!(org.as_deref(), Some(OWNER_ORG));
}
#[tokio::test]
async fn get_draft_org_id_returns_none_for_an_unknown_draft() {
let db = db_with_draft().await;
let org = get_draft_org_id_in(&db, "missing").await.unwrap();
assert_eq!(org, None);
}
}
+13 -4
View File
@@ -193,7 +193,7 @@ pub(crate) async fn process(msg: Message) -> Result<()> {
match existing {
Some(_) => {
log::info!("updating existing workflow draft for {org_id}/{id}");
match infra::table::workflows::update_draft(draft.clone()).await {
match infra::table::workflows::update_draft(&org_id, draft.clone()).await {
Ok(_) => {}
Err(e) => {
log::error!(
@@ -220,10 +220,19 @@ pub(crate) async fn process(msg: Message) -> Result<()> {
MessageType::WorkflowDraftDelete => {
let id: String = json::from_slice(&msg.value.unwrap())?;
log::info!("received workflow draft delete for {id}");
match infra::table::workflows::delete_draft(&id).await {
Ok(_) => {}
match infra::table::workflows::get_draft_org_id(&id).await {
Ok(Some(org_id)) => {
if let Err(e) = infra::table::workflows::delete_draft(&org_id, &id).await {
log::error!(
"error while deleting workflow draft from db {org_id}/{id} : {e}"
);
}
}
Ok(None) => {
log::debug!("workflow draft {id} not found, nothing to delete");
}
Err(e) => {
log::error!("error while deleting workflow draft from db {id} : {e}");
log::error!("error while looking up workflow draft {id} for delete : {e}");
}
}
log::info!("successfully handled workflow draft delete for {id}");
+13
View File
@@ -119,6 +119,19 @@ file should pass strict ruff.
| `serial` | Reads or changes server-wide state (root passcode, shared seed stream, org-wide counts). Jobs that run the default set with `-n` (OSS `api_integration_tests`, ENT `oss_in_ent`) run `-m "not serial" -n 4 --dist loadfile`, then `-m serial` alone; mark any new test of that kind. |
| `order(N)` | Run order pin (legacy — avoid in new tests). |
## Enterprise suites with their own setup
CI copies `o2-enterprise/tests/*` over `tests/` and runs these suites in their own shards, never in the full run. Locally, deselect them from `rye run pytest` with `--ignore=tests/fleet/ --ignore=tests/fleet_flag_off/ --ignore=tests/fleet_collector/ --ignore=tests/rbac/fleet/`.
| Suite | Needs | Run |
|---|---|---|
| `tests/fleet/` | An enterprise server with `O2_FLEET_ENABLED=true` (the default) | `rye run pytest tests/fleet/ -n 4 --dist loadfile` |
| `tests/fleet_flag_off/` | Two starts over the same `./data`: first with `O2_FLEET_ENABLED=true` to seed a real token, then with `O2_FLEET_ENABLED=false` (fails otherwise) | `FLEET_FLAG_OFF_SEED=/tmp/seed.json PYTHONPATH=.:tests rye run python -m fleet_flag_off.seed_token`, restart the server with the flag off, then `FLEET_FLAG_OFF_SEED=/tmp/seed.json rye run pytest tests/fleet_flag_off/` |
| `tests/fleet_collector/` | An enterprise server with `O2_FLEET_ENABLED=true`, a running Docker daemon, and the collector image pinned as `IMAGE_REF` in `tests/fleet_collector/collector_docker.py` (pulled on first use). Containers reach the server through `host.docker.internal`. | `rye run pytest tests/fleet_collector/` (no `-n`) |
| `tests/rbac/fleet/` | An enterprise server with `O2_OPENFGA_ENABLED=true`, `O2_OPENFGA_LIST_ONLY_PERMITTED=true` (the filtered-list checks need it) and a running OpenFGA server; CI runs it in the `ent_rbac` shard | `rye run pytest tests/rbac/fleet/` |
Fleet runs leave `fl_auto_*` orgs behind, because org delete is cloud-only. Most are empty, but the flag-off seed org keeps its fleet and live token, and the fleet suite's orgs keep a disabled ingestion token. Remove them by hand before reusing a local server.
## Troubleshooting
- **`KeyError: 'ZO_BASE_URL'` on collection** — `conftest.py` reads env vars at import. Export them before running pytest (even `--collect-only`).
@@ -0,0 +1,24 @@
"""Fleet management is served by the enterprise build only; the edition comes from `/config`, so both branches run."""
from __future__ import annotations
from support.client import OpenObserveClient
ENTERPRISE_BUILDS = ("enterprise", "cloud")
def test_fl_gate_02_04_fleet_routes_follow_the_edition(client: OpenObserveClient):
config = client.get("config")
assert config.status_code == 200, config.text
build_type = config.json()["build_type"]
fleets = client.get("fleet/fleets")
if build_type in ENTERPRISE_BUILDS:
assert config.json()["fleet_enabled"] is True, config.json()
assert fleets.status_code == 200, fleets.text
assert "default" in {f["id"] for f in fleets.json()["items"]}, fleets.text
else:
assert build_type == "opensource", build_type
assert config.json()["fleet_enabled"] is False, config.json()
# The OSS router never registers the fleet routes (enterprise-only `fleet_routes`), so there is no 403 stub.
assert fleets.status_code == 404, fleets.text
+44
View File
@@ -3712,6 +3712,50 @@ class APICleanup {
return removed;
}
/** Crashed-run fleet leftovers; quiet on OSS, where the fleet routes are not registered and answer 404. */
async cleanupFleetArtifacts(fleetPrefixes = ['fl_auto_'], configPrefixes = ['fl-auto-']) {
testLogger.info('Starting fleet artifact cleanup', { fleetPrefixes, configPrefixes });
const headers = { 'Authorization': this.authHeader, 'Content-Type': 'application/json' };
const api = (path) => `${this.baseUrl}/api/${this.org}${path}`;
const json = async (res) => { try { return await res.json(); } catch { return null; } };
const starts = (value, prefixes) => typeof value === 'string' && prefixes.some((p) => value.startsWith(p));
const removed = { tokens: 0, fleets: 0, configs: 0 };
// The fl_auto_ roles and users do not depend on the fleet API, so sweep them even when it is unavailable.
for (const prefix of fleetPrefixes) await this.cleanupIamArtifacts(prefix);
try {
const fleetsRes = await this._fetch(api('/fleet/fleets?offset=0&limit=1000'), { method: 'GET', headers });
if (!fleetsRes.ok) {
testLogger.info('Fleet cleanup skipped — fleet API unavailable (OSS build or fleet off)', { status: fleetsRes.status });
return removed;
}
const fleets = ((await json(fleetsRes))?.items ?? []).filter((f) => starts(f?.name, fleetPrefixes));
const ids = new Set(fleets.map((f) => f.id));
const tokensRes = await this._fetch(api('/fleet/tokens?offset=0&limit=1000'), { method: 'GET', headers });
for (const tok of (tokensRes.ok ? (await json(tokensRes))?.items : null) ?? []) {
if (ids.has(tok?.fleet?.id) && !tok.revoked) {
await this._fetch(api(`/fleet/tokens/${tok.id}/revoke`), { method: 'POST', headers });
removed.tokens += 1;
}
}
// Deleting a fleet also removes its revoked tokens and rollout history, which unpins its configurations.
for (const f of fleets) {
await this._fetch(api(`/fleet/fleets/${f.id}`), { method: 'DELETE', headers });
removed.fleets += 1;
}
const configsRes = await this._fetch(api('/fleet/configs?offset=0&limit=1000'), { method: 'GET', headers });
for (const c of (configsRes.ok ? (await json(configsRes))?.items : null) ?? []) {
if (starts(c?.id, configPrefixes)) {
await this._fetch(api(`/fleet/configs/${c.id}`), { method: 'DELETE', headers });
removed.configs += 1;
}
}
testLogger.info('Fleet artifact cleanup complete', removed);
} catch (e) {
testLogger.warn('Fleet artifact cleanup hit an error — continuing', { error: e?.message });
}
return removed;
}
/**
* Verify stream exists via API
* @param {string} streamName - Name of the stream to verify
@@ -0,0 +1,203 @@
const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
const PAGE_TIMEOUT_MS = 30000;
class FleetConfigPage {
constructor(page) {
this.page = page;
this.editorPage = '[data-test="fleet-config-editor-page"]';
this.backLink = '[data-test="app-page-header-back"]';
this.modeChip = '[data-test="fleet-config-editor-mode-chip"]';
this.visualMode = '[data-test="fleet-config-editor-mode-visual"]';
// The YAML toggle item has no data-test of its own; reka renders its value on the button.
this.yamlMode = '[data-test="fleet-config-editor-mode"] button[value="yaml"]';
this.yamlEditor = '[data-test="fleet-config-editor-yaml"]';
this.canvas = '[data-test="fleet-editor-canvas"]';
this.validateBtn = '[data-test="fleet-config-editor-validate"]';
this.validationChip = '[data-test="fleet-config-editor-validation"]';
this.publishBtn = '[data-test="fleet-config-editor-publish"]';
this.publishDialog = '[data-test="fleet-config-editor-publish-dialog"]';
this.publishMessageField = '[data-test="fleet-config-editor-publish-message-field"]';
this.dialogPrimaryBtn = '[data-test="o-dialog-primary-btn"]';
this.startRolloutBtn = '[data-test="fleet-config-editor-start-rollout"]';
this.historyBtn = '[data-test="fleet-config-editor-history"]';
this.history = '[data-test="fleet-config-history"]';
this.diff = '[data-test="fleet-config-diff"]';
this.properties = '[data-test="fleet-component-properties"]';
this.newSettingKeyField = '[data-test="fleet-component-properties-new-key-field"]';
this.newSettingValueField = '[data-test="fleet-component-properties-new-value-field"]';
this.addSettingSubmit = '[data-test="fleet-component-properties-add-submit"]';
this.successToast = '[data-test^="o-toast-"][data-test-variant="success"]';
}
paletteItem(kind, key) {
return this.page.locator(`[data-test="fleet-component-palette-item-${kind}-${key}"]`);
}
node(nodeId) {
return this.page.locator(`[data-test=${JSON.stringify(`fleet-editor-node-${nodeId}`)}]`);
}
// Dotted keys carry quotes and dots, so the attribute value is a quoted CSS string.
propertyField(key) {
return this.page.locator(`[data-test=${JSON.stringify(`fleet-component-properties-field-${key}-field`)}]`);
}
historyCompare(version) {
return this.page.locator(`[data-test="fleet-config-history-compare-${version}"]`);
}
async goto(configId) {
const url = `${process.env.ZO_BASE_URL}/web/fleet/configurations/${encodeURIComponent(configId)}?org_identifier=${getOrgIdentifier()}`;
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
await expect(this.page.locator(this.editorPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.validateBtn)).toBeEnabled({ timeout: PAGE_TIMEOUT_MS });
}
async expectTitle(configId) {
await expect(this.page.locator(this.editorPage).getByRole('heading', { name: configId })).toBeVisible();
}
async clickBack() {
await this.page.locator(this.backLink).click();
}
async expectVisualMode() {
await expect(this.page.locator(this.visualMode)).toHaveAttribute('data-state', 'on', { timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.canvas)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async switchToYaml() {
if ((await this.page.locator(this.yamlMode).getAttribute('data-state')) !== 'on') await this.page.locator(this.yamlMode).click();
await expect(this.page.locator(this.yamlMode)).toHaveAttribute('data-state', 'on');
await expect(this.page.locator(this.yamlEditor)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async switchToVisual() {
await expect(this.page.locator(this.visualMode)).toBeEnabled({ timeout: PAGE_TIMEOUT_MS });
await this.page.locator(this.visualMode).click();
await this.expectVisualMode();
}
async expectVisualUnavailable() {
await expect(this.page.locator(this.visualMode)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS });
}
async monaco(action, text) {
return await this.page.locator(this.yamlEditor).evaluate(
(node, { action, text }) => {
const editor = window.monaco?.editor?.getEditors?.().find((e) => node.contains(e.getDomNode()));
if (!editor) return null;
if (action === 'set') {
editor.setValue(text);
// The editor emits after a 500ms debounce that only a text blur flushes; unflushed, a quick switch to Visual drops the edit.
editor.focus();
if (!node.contains(document.activeElement)) throw new Error('YAML editor did not take focus');
document.activeElement.blur();
}
return editor.getValue();
},
{ action, text },
);
}
async readYaml() {
await expect.poll(() => this.monaco('read'), { timeout: PAGE_TIMEOUT_MS }).not.toBeNull();
return await this.monaco('read');
}
async expectYaml(expected) {
await expect.poll(async () => ((await this.monaco('read')) ?? '').trimEnd(), { timeout: PAGE_TIMEOUT_MS }).toBe(
expected.trimEnd(),
);
}
async setYaml(text) {
await expect.poll(() => this.monaco('read'), { timeout: PAGE_TIMEOUT_MS }).not.toBeNull();
await this.monaco('set', text);
testLogger.info('YAML editor set', { lines: text.split('\n').length });
}
async addFromPalette(kind, key, nodeId) {
await this.paletteItem(kind, key).click();
await expect(this.node(nodeId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async selectNode(nodeId) {
await expect(this.node(nodeId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await this.node(nodeId).click();
await expect(this.page.locator(this.properties)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectPropertyValue(key, value) {
await expect(this.propertyField(key)).toHaveValue(value, { timeout: PAGE_TIMEOUT_MS });
}
async expectNoProperty(key) {
await expect(this.propertyField(key)).toHaveCount(0);
}
async setProperty(key, value) {
await this.propertyField(key).fill(value);
await expect(this.propertyField(key)).toHaveValue(value);
}
async addSetting(key, value) {
await this.page.locator(this.newSettingKeyField).fill(key);
await this.page.locator(this.newSettingValueField).fill(value);
await this.page.locator(this.addSettingSubmit).click();
await this.expectPropertyValue(key, value);
}
async validate() {
await this.page.locator(this.validateBtn).click();
}
async expectValidationChip(text) {
await expect(this.page.locator(this.validationChip)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
async publish(message) {
await this.page.locator(this.publishBtn).click();
const dialog = this.page.locator(this.publishDialog);
await expect(dialog).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await dialog.locator(this.publishMessageField).fill(message);
await dialog.locator(this.dialogPrimaryBtn).click();
}
async expectModeChip(text) {
await expect(this.page.locator(this.modeChip)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
async expectSuccessToast(text) {
await expect(this.page.locator(this.successToast).filter({ hasText: text }).first()).toBeVisible({
timeout: PAGE_TIMEOUT_MS,
});
}
async openHistory() {
await this.page.locator(this.historyBtn).click();
await expect(this.page.locator(this.history)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectCompareDisabled(version) {
await expect(this.historyCompare(version)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS });
}
async compare(version) {
await this.historyCompare(version).click();
await expect(this.page.locator(this.diff)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectDiffContains(text) {
await expect(this.page.locator(this.diff)).toContainText(text);
}
async openDeployFromEditor() {
await this.page.locator(this.startRolloutBtn).click();
}
}
module.exports = FleetConfigPage;
@@ -0,0 +1,65 @@
const { expect } = require('@playwright/test');
const PAGE_TIMEOUT_MS = 30000;
const DASH = '—';
class FleetFleetsPage {
constructor(page) {
this.page = page;
this.rail = '[data-test="fleet-rail"]';
this.panel = '[data-test="fleet-fleets-panel"]';
this.panelName = '[data-test="fleet-fleets-name"]';
this.addCollectorBtn = '[data-test="fleet-fleets-add-collector"]';
this.deployBtn = '[data-test="fleet-fleets-change-version"]';
this.nameDialog = '[data-test="fleet-rail-name-dialog"]';
this.nameDialogField = '[data-test="fleet-rail-name-dialog-name-field"]';
this.dialogPrimaryBtn = '[data-test="o-dialog-primary-btn"]';
this.rolloutsDrawer = '[data-test="fleet-rollouts-drawer"]';
this.rolloutStatus = '[data-test="fleet-rollouts-status"]';
}
railItem(fleetId) {
return this.page.locator(`[data-test="fleet-rail-item-${fleetId}"]`);
}
async expectPanelFor(name) {
await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.panelName)).toHaveText(name);
}
async expectRailItemActive(fleetId) {
await expect(this.railItem(fleetId)).toHaveAttribute('data-state', 'active', { timeout: PAGE_TIMEOUT_MS });
}
// The rail shows a collector count only for a fleet the caller may read, and a dash otherwise.
async expectRailItemReadable(fleetId, readable) {
const item = this.railItem(fleetId);
await expect(item).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
if (readable) await expect(item).not.toContainText(DASH);
else await expect(item).toContainText(DASH);
}
async openAddCollector() {
await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await this.page.locator(this.addCollectorBtn).click();
}
async openDeploy() {
await expect(this.page.locator(this.panel)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await this.page.locator(this.deployBtn).click();
}
async submitNewFleet(name) {
const dialog = this.page.locator(this.nameDialog);
await expect(dialog).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await dialog.locator(this.nameDialogField).fill(name);
await dialog.locator(this.dialogPrimaryBtn).click();
}
async expectRolloutDrawerStatus(text) {
await expect(this.page.locator(this.rolloutsDrawer)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.rolloutStatus)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
}
module.exports = FleetFleetsPage;
@@ -0,0 +1,301 @@
const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const PAGE_TIMEOUT_MS = 30000;
const SCRIPT_BASE = 'https://raw.githubusercontent.com/openobserve/agents/main';
const IMAGE = 'o2cr.ai/openobserve/otel-fleet-collector';
const HELM_NAMESPACE = 'openobserve-collector';
const AGENT_RELEASE = 'otel-fleet-collector';
const GATEWAY_RELEASE = 'otel-fleet-collector-gateway';
const MASK = '•'.repeat(12);
class FleetInstallPage {
constructor(page) {
this.page = page;
this.dialog = '[data-test="fleet-install-dialog"]';
this.noToken = '[data-test="fleet-install-dialog-no-token"]';
this.tokenTrigger = '[data-test="fleet-install-dialog-token-trigger"]';
this.tokenOption = '[data-test="fleet-install-dialog-token-option"]';
this.joinsText = '[data-test="fleet-install-dialog-fleet"]';
this.copyOnce = '[data-test="fleet-install-dialog-copy-once"]';
this.placeholderHint = '[data-test="fleet-install-dialog-placeholder-hint"]';
this.waiting = '[data-test="fleet-install-dialog-waiting"]';
this.tokenFormName = '[data-test="fleet-token-form-name-field"]';
this.tokenFormSubmit = '[data-test="fleet-token-form-submit"]';
this.command = '[data-test="fleet-install-command"]';
this.commandCode = '[data-test="fleet-install-command-code-pre"] code';
this.revealBtn = '[data-test="fleet-install-command-code-reveal-btn"]';
this.copyBtn = '[data-test="fleet-install-command-code-copy-btn"]';
this.websocket = '[data-test="fleet-install-command-websocket"] button[role="checkbox"]';
this.clusterNameField = '[data-test="fleet-install-command-cluster-name-field"]';
this.uninstallTrigger = '[data-test="fleet-install-command-uninstall"] button[aria-expanded]';
this.uninstallCode = '[data-test="fleet-uninstall-command-code-pre"] code';
}
osButton(platform) {
return this.page.locator(`[data-test="fleet-install-command-os-${platform}"]`);
}
roleButton(role) {
return this.page.locator(`[data-test="fleet-install-command-role-${role}"]`);
}
static resolveBaseUrl(endpoint, origin) {
let url;
try {
url = new URL(endpoint || '/', origin);
} catch {
url = new URL(origin);
}
return `${url.origin}${url.pathname}`.replace(/\/+$/, '');
}
static opampEndpoint(t) {
const url = new URL(t.baseUrl);
const scheme = t.http ? url.protocol.replace(':', '') : url.protocol === 'https:' ? 'wss' : 'ws';
return `${scheme}://${url.host}${url.pathname.replace(/\/+$/, '')}/api/${t.org}/v1/opamp`;
}
static expectedInstallLines(platform, t) {
const q = (v) => `'${v.replace(/'/g, `'\\''`)}'`;
const join = (args, cont) => args.map((a, i) => ` ${a}${i < args.length - 1 ? cont : ''}`);
if (platform === 'linux' || platform === 'macos') {
const args = [
`--url ${q(t.baseUrl)}`,
`--org ${q(t.org)}`,
`--token ${q(t.token)}`,
...(t.version ? [`--version ${q(t.version)}`] : []),
`--transport ${t.http ? 'http' : 'ws'}`,
];
return [`curl -fsSL ${SCRIPT_BASE}/fleet/install.sh | sudo bash -s -- \\`, ...join(args, ' \\')];
}
if (platform === 'windows') {
const ps = (v) => `'${v.replace(/'/g, "''")}'`;
const args = [
`-Url ${ps(t.baseUrl)}`,
`-Org ${ps(t.org)}`,
`-Token ${ps(t.token)}`,
...(t.version ? [`-Version ${ps(t.version)}`] : []),
`-Transport ${t.http ? 'http' : 'ws'}`,
];
return [`& ([scriptblock]::Create((irm ${SCRIPT_BASE}/fleet/install.ps1))) \``, ...join(args, ' `')];
}
if (platform === 'docker') {
const baseUrl = t.baseUrl.replace(/\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, '//host.docker.internal');
const insecure = !baseUrl.startsWith('https:');
return [
`AUTH=$(printf 'fleet:%s' ${q(t.token)} | base64 | tr -d '\\n')`,
'mkdir -p otel-fleet && cat > otel-fleet/supervisor.yaml <<EOF',
'server:',
` endpoint: ${FleetInstallPage.opampEndpoint({ ...t, baseUrl })}`,
' headers:',
' Authorization: "Basic $AUTH"',
...(insecure ? [' tls:', ' insecure: true'] : []),
'capabilities:',
' accepts_remote_config: true',
' reports_effective_config: true',
' reports_health: true',
' reports_own_metrics: true',
' accepts_restart_command: true',
' reports_available_components: true',
'agent:',
' executable: /usr/local/bin/otelcol-contrib',
' config_apply_timeout: 30s',
' env:',
` O2_ENDPOINT: ${baseUrl.replace(/\/+$/, '')}/api/${t.org}`,
' O2_TOKEN: "Basic $AUTH"',
'storage:',
' directory: /var/lib/opampsupervisor',
'EOF',
'docker rm -f otel-fleet-collector >/dev/null 2>&1 || true',
'docker run -d --name otel-fleet-collector --restart unless-stopped \\',
' --hostname "$(hostname)" \\',
' -v "$PWD/otel-fleet/supervisor.yaml:/etc/opampsupervisor/supervisor.yaml:ro" \\',
' -v otel-fleet-state:/var/lib/opampsupervisor \\',
` ${IMAGE}:${t.version ?? 'latest'}`,
];
}
const gateway = t.role === 'gateway';
const cluster = t.clusterName?.trim().replace(/\\/g, '\\\\').replace(/,/g, '\\,');
const sets = [
...(gateway ? ['--set agent.enabled=false', '--set gateway.enabled=true'] : []),
...(cluster ? [`--set-string clusterName=${q(cluster)}`] : []),
`--set image.repository=${IMAGE}`,
...(t.version ? [`--set image.tag=${q(t.version)}`] : []),
`--set opamp.endpoint=${q(FleetInstallPage.opampEndpoint(t))}`,
`--set opamp.token=${q(t.token)}`,
];
return [
'helm repo add openobserve https://charts.openobserve.ai && helm repo update',
`helm upgrade --install ${gateway ? GATEWAY_RELEASE : AGENT_RELEASE} openobserve/otel-fleet-collector \\`,
` --namespace ${HELM_NAMESPACE} --create-namespace \\`,
...join(sets, ' \\'),
];
}
static expectedUninstallLines(platform, role = 'agent') {
if (platform === 'windows') return [`& ([scriptblock]::Create((irm ${SCRIPT_BASE}/fleet/install.ps1))) -Uninstall`];
if (platform === 'docker') {
return ['docker rm -f otel-fleet-collector', 'docker volume rm otel-fleet-state', 'rm -rf otel-fleet'];
}
if (platform === 'kubernetes') {
return [`helm uninstall ${role === 'gateway' ? GATEWAY_RELEASE : AGENT_RELEASE} --namespace ${HELM_NAMESPACE}`];
}
return [`curl -fsSL ${SCRIPT_BASE}/fleet/install.sh | sudo bash -s -- --uninstall`];
}
static maskToken(token) {
return token.slice(0, 4) + MASK + token.slice(-4);
}
// The command targets the ingestion URL, or the app's own base when none is configured.
async installTarget(seed, token) {
const ingestion = await seed.ingestionBaseUrl();
const url = new URL(this.page.url());
const pos = url.pathname.indexOf('/web/');
const appBase = pos > -1 ? url.origin + url.pathname.slice(0, pos) : url.origin;
const target = {
baseUrl: FleetInstallPage.resolveBaseUrl(ingestion || appBase, url.origin),
org: seed.orgId(),
token,
version: await seed.targetCollectorVersion(),
};
testLogger.info('Install target', { baseUrl: target.baseUrl, org: target.org, version: target.version });
return target;
}
async expectDialogOpen() {
await expect(this.page.locator(this.dialog)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectCommandShown() {
await expect(this.page.locator(this.commandCode)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
// A single-choice toggle deselects its active item on a second click, so only an inactive item is clicked.
async selectPlatform(platform) {
if ((await this.osButton(platform).getAttribute('data-state')) !== 'on') await this.osButton(platform).click();
await expect(this.osButton(platform)).toHaveAttribute('data-state', 'on');
}
async selectRole(role) {
if ((await this.roleButton(role).getAttribute('data-state')) !== 'on') await this.roleButton(role).click();
await expect(this.roleButton(role)).toHaveAttribute('data-state', 'on');
}
async expectWebsocket(checked) {
await expect(this.page.locator(this.websocket)).toHaveAttribute('aria-checked', String(checked));
}
async setWebsocket(checked) {
const box = this.page.locator(this.websocket);
if ((await box.getAttribute('aria-checked')) !== String(checked)) await box.click();
await this.expectWebsocket(checked);
}
async revealToken() {
await this.page.locator(this.revealBtn).click();
}
async setClusterName(name) {
await this.page.locator(this.clusterNameField).fill(name);
}
async commandText() {
return (await this.page.locator(this.commandCode).textContent()) ?? '';
}
async expectCommandLines(lines) {
await expect.poll(async () => (await this.commandText()).split('\n'), { timeout: PAGE_TIMEOUT_MS }).toEqual(lines);
}
async expectCommandMasked(token) {
await expect.poll(() => this.commandText(), { timeout: PAGE_TIMEOUT_MS }).toContain(FleetInstallPage.maskToken(token));
expect(await this.commandText()).not.toContain(token);
}
async expandUninstall() {
const trigger = this.page.locator(this.uninstallTrigger).first();
if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click();
await expect(this.page.locator(this.uninstallCode)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectUninstallLines(lines, token) {
await expect
.poll(async () => ((await this.page.locator(this.uninstallCode).textContent()) ?? '').split('\n'), {
timeout: PAGE_TIMEOUT_MS,
})
.toEqual(lines);
expect(await this.page.locator(this.uninstallCode).textContent()).not.toContain(token);
}
async copyCommand() {
await this.page.locator(this.copyBtn).click();
return await this.page.evaluate(() => navigator.clipboard.readText());
}
async expectNoTokenState() {
await expect(this.page.locator(this.noToken)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
// The dialog must read GET /fleet/collectors again once a token exists, or it never notices the collector.
async createInlineTokenAndAwaitCollectorsPoll(name) {
let created = false;
const onResponse = (resp) => {
if (resp.request().method() === 'POST' && /\/fleet\/tokens$/.test(new URL(resp.url()).pathname) && resp.ok()) {
created = true;
}
};
this.page.on('response', onResponse);
try {
const createdResp = this.page.waitForResponse(
(r) => r.request().method() === 'POST' && /\/fleet\/tokens$/.test(new URL(r.url()).pathname),
);
const polled = this.page.waitForResponse(
(r) => created && r.request().method() === 'GET' && /\/fleet\/collectors$/.test(new URL(r.url()).pathname),
{ timeout: PAGE_TIMEOUT_MS },
);
await this.page.locator(this.noToken).locator(this.tokenFormName).fill(name);
await this.page.locator(this.noToken).locator(this.tokenFormSubmit).click();
const resp = await createdResp;
expect(resp.status(), 'token create from the dialog failed').toBe(201);
const pollResp = await polled;
expect(pollResp.status()).toBe(200);
return await resp.json();
} finally {
this.page.off('response', onResponse);
}
}
async expectCopyOnceAndWaiting() {
await expect(this.page.locator(this.copyOnce)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.waiting)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.placeholderHint)).toHaveCount(0);
}
async expectSelectedToken(label) {
await expect(this.page.locator(this.tokenTrigger)).toHaveAttribute('data-test-selected-label', label, {
timeout: PAGE_TIMEOUT_MS,
});
}
async tokenOptionLabels() {
const trigger = this.page.locator(this.tokenTrigger);
await expect(async () => {
if ((await trigger.getAttribute('aria-expanded')) !== 'true') await trigger.click();
await expect(trigger).toHaveAttribute('aria-expanded', 'true', { timeout: 2000 });
}).toPass({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.tokenOption).first()).toBeVisible();
const labels = await this.page.locator(this.tokenOption).evaluateAll((els) =>
els.map((e) => e.getAttribute('data-test-label')),
);
await this.page.keyboard.press('Escape');
return labels;
}
async expectJoinsText(text) {
await expect(this.page.locator(this.joinsText)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
}
module.exports = FleetInstallPage;
@@ -0,0 +1,219 @@
const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
const PAGE_TIMEOUT_MS = 30000;
class FleetManagerPage {
constructor(page) {
this.page = page;
this.navDataTile = '[data-test="menu-link-\\/streams-item"]';
this.navFleetItem = '[data-test="nav-group-item-fleet"]';
this.managerPage = '[data-test="fleet-manager-page"]';
this.flowOverview = '[data-test="fleet-flow-overview"]';
this.flowError = '[data-test="fleet-flow-overview-error"]';
this.flowNoFleets = '[data-test="fleet-flow-overview-no-fleets"]';
this.fleetsArea = '[data-test="fleet-fleets"]';
this.fleetsNoAccess = '[data-test="fleet-fleets-no-access"]';
this.fleetsNotFound = '[data-test="fleet-fleets-not-found"]';
this.fleetsPanel = '[data-test="fleet-fleets-panel"]';
this.tableForbidden = '[data-test="o2-table-forbidden"]';
this.collectorsScopeAll = '[data-test="fleet-collectors-scope-all"]';
this.columnToggleBtn = '[data-test="o2-table-column-toggle-btn"]';
this.columnTogglePanel = '[data-test="o2-table-column-toggle-panel"]';
this.columnResetBtn = '[data-test="o2-table-column-toggle-reset-btn"]';
this.settingsRoot = '[data-test="fleet-settings"]';
this.settingsTargetVersion = '[data-test="fleet-settings-target-version"]';
this.settingsAlerts = '[data-test="fleet-settings-alerts"]';
this.settingsAlertLibrary = '[data-test="fleet-settings-alert-library"]';
this.errorToast = '[data-test^="o-toast-"][data-test-variant="error"]';
}
tab(name) {
return this.page.locator(`[data-test="fleet-manager-tab-${name}"]`);
}
createButton(tab) {
return this.page.locator(`[data-test="fleet-manager-create-${tab}"]`);
}
collectorStat(key) {
return this.page.locator(`[data-test="fleet-collectors-stat-${key}"]`);
}
tableHeader(columnId) {
return this.page.locator(`th[data-test="o2-table-th-${columnId}"]`);
}
columnToggleCheckbox(columnId) {
return this.page.locator(`[data-test="o2-table-column-toggle-item-${columnId}"] button[role="checkbox"]`);
}
columnToggleItem(columnId) {
return this.page.locator(`[data-test="o2-table-column-toggle-item-${columnId}"]`);
}
// Fail loudly, never skip: these specs run only on an enterprise build with fleet management on.
async assertEnabled() {
const base = process.env.ZO_BASE_URL.replace(/\/$/, '');
// The unauthenticated /config is the login bootstrap and has no fleet_enabled; the org config does.
const config = await this.page.request.get(`${base}/api/${getOrgIdentifier()}/config`, { headers: getAuthHeaders() });
const fleetEnabled = config.ok() ? (await config.json())?.fleet_enabled : undefined;
const list = await this.page.request.get(`${base}/api/${getOrgIdentifier()}/fleet/fleets?offset=0&limit=1`, {
headers: getAuthHeaders(),
});
if (fleetEnabled !== true || list.status() !== 200) {
throw new Error(
`Fleet management is NOT available on ${base} (/config fleet_enabled=${fleetEnabled}, GET /fleet/fleets -> ${list.status()}). ` +
'These specs are enterprise-only and must never be skipped: run them on an enterprise build with O2_FLEET_ENABLED=true.',
);
}
}
async gotoFleet(query = {}) {
const params = new URLSearchParams({ org_identifier: getOrgIdentifier(), ...query });
await this.page.goto(`${process.env.ZO_BASE_URL}/web/fleet?${params}`, { waitUntil: 'domcontentloaded' });
await expect(this.page.locator(this.managerPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async openFromNav() {
const tile = this.page.locator(this.navDataTile);
const item = this.page.locator(this.navFleetItem);
await expect(tile).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
// The flyout opens on hover after a delay and closes on scroll, so open-and-reveal retries as a unit.
await expect(async () => {
await this.page.mouse.move(0, 0);
await tile.hover();
await expect(item).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: PAGE_TIMEOUT_MS });
await item.click({ force: true });
await expect(this.page.locator(this.managerPage)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async clickCreate(tab) {
await this.createButton(tab).click();
}
async clickTab(name) {
await this.tab(name).click();
await this.expectTabActive(name);
}
async expectTabActive(name) {
await expect(this.tab(name)).toHaveAttribute('data-state', 'active', { timeout: PAGE_TIMEOUT_MS });
}
async expectOnFleetRoute(query = {}) {
await expect
.poll(() => {
const url = new URL(this.page.url());
if (!url.pathname.replace(/\/+$/, '').endsWith('/fleet')) return `path ${url.pathname}`;
for (const [k, v] of Object.entries(query)) {
if (url.searchParams.get(k) !== v) return `${k}=${url.searchParams.get(k)}`;
}
return 'ok';
}, { timeout: PAGE_TIMEOUT_MS, message: `URL never matched /web/fleet ${JSON.stringify(query)}` })
.toBe('ok');
}
async expectQueryParam(key, value) {
await expect.poll(() => new URL(this.page.url()).searchParams.get(key), { timeout: PAGE_TIMEOUT_MS }).toBe(value);
}
async goBackAndExpectLeftFleet() {
await this.page.goBack({ waitUntil: 'domcontentloaded' });
await expect
.poll(() => new URL(this.page.url()).pathname, { timeout: PAGE_TIMEOUT_MS })
.not.toMatch(/\/fleet\/?$/);
}
async expectOverviewVisible() {
await this.expectTabActive('overview');
await expect(this.page.locator(this.flowOverview)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectOverviewNoFleetsWithoutErrorBanner() {
await expect(this.page.locator(this.flowNoFleets)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.flowError)).toHaveCount(0);
}
async expectFleetNoAccess(text) {
const panel = this.page.locator(this.fleetsNoAccess);
await expect(panel).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
if (text) await expect(panel).toContainText(text);
}
async expectCollectorsForbidden() {
await expect(this.page.locator(this.fleetsArea).locator(this.tableForbidden)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectCollectorsNotForbidden() {
await expect(this.page.locator(this.fleetsArea).locator(this.tableForbidden)).toHaveCount(0);
}
async expectFleetNotFound(text) {
const panel = this.page.locator(this.fleetsNotFound);
await expect(panel).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(panel).toContainText(text);
}
async expectFleetPanelHidden() {
await expect(this.page.locator(this.fleetsPanel)).toHaveCount(0);
}
async expectAllFleetsScope() {
await expect(this.page.locator(this.collectorsScopeAll)).toHaveAttribute('data-state', 'on', { timeout: PAGE_TIMEOUT_MS });
await expect(this.tableHeader('fleet')).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectStatPressed(key) {
await expect(this.collectorStat(key)).toHaveAttribute('aria-pressed', 'true', { timeout: PAGE_TIMEOUT_MS });
}
async openColumnMenu() {
const panel = this.page.locator(this.columnTogglePanel);
await expect(async () => {
if (!(await panel.isVisible())) await this.page.locator(this.columnToggleBtn).first().click();
await expect(panel).toBeVisible({ timeout: 2000 });
}).toPass({ timeout: PAGE_TIMEOUT_MS });
}
async expectColumnChecked(columnId, checked) {
await this.openColumnMenu();
await expect(this.columnToggleCheckbox(columnId)).toHaveAttribute('data-state', checked ? 'checked' : 'unchecked');
}
async toggleColumn(columnId) {
await this.openColumnMenu();
await this.columnToggleItem(columnId).click();
}
async resetColumns() {
await this.openColumnMenu();
await this.page.locator(this.columnResetBtn).click();
}
async expectColumnShown(columnId, shown) {
if (shown) await expect(this.tableHeader(columnId)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
else await expect(this.tableHeader(columnId)).toHaveCount(0);
}
async expectSettingsWithoutFleetAlerts(removedTexts) {
await expect(this.page.locator(this.settingsTargetVersion)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.settingsAlerts)).toHaveCount(0);
await expect(this.page.locator(this.settingsAlertLibrary)).toHaveCount(0);
const settings = this.page.locator(this.settingsRoot);
for (const text of removedTexts) await expect(settings).not.toContainText(text);
}
async expectErrorToast(text) {
await expect(this.page.locator(this.errorToast).filter({ hasText: text }).first()).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
testLogger.info('Error toast shown', { text });
}
async expectAnyErrorToast() {
await expect(this.page.locator(this.errorToast).first()).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
}
module.exports = FleetManagerPage;
@@ -0,0 +1,71 @@
const { expect } = require('@playwright/test');
const PAGE_TIMEOUT_MS = 30000;
class FleetRolloutPage {
constructor(page) {
this.page = page;
this.dialog = '[data-test="fleet-new-rollout-dialog"]';
this.fleetTrigger = '[data-test="fleet-new-rollout-dialog-fleet-trigger"]';
this.fleetError = '[data-test="fleet-new-rollout-dialog-fleet-error"]';
this.start = '[data-test="fleet-new-rollout-dialog-start"]';
// The start and batching items have no data-test; reka renders each item's value on its button.
this.startAt = '[data-test="fleet-new-rollout-dialog-start"] button[value="at"]';
this.firstField = '[data-test="fleet-new-rollout-dialog-first-field"]';
this.firstError = '[data-test="fleet-new-rollout-dialog-first-error"]';
this.initialField = '[data-test="fleet-new-rollout-dialog-initial-field"]';
this.maxField = '[data-test="fleet-new-rollout-dialog-max-field"]';
this.maxError = '[data-test="fleet-new-rollout-dialog-max-error"]';
this.primaryBtn = '[data-test="o-dialog-primary-btn"]';
}
batchingItem(kind) {
return this.page.locator(`[data-test="fleet-new-rollout-dialog-batching"] button[value="${kind}"]`);
}
async expectReady() {
await expect(this.page.locator(this.dialog)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.start)).toBeVisible({ timeout: PAGE_TIMEOUT_MS });
}
async expectNoFleetPicked() {
await expect(this.page.locator(this.fleetTrigger)).toHaveAttribute('data-test-selected-value', '');
}
async submit() {
await this.page.locator(this.dialog).locator(this.primaryBtn).click();
}
async expectFleetError(text) {
await expect(this.page.locator(this.fleetError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
await expect(this.page.locator(this.dialog)).toBeVisible();
}
async expectScheduledDisabled() {
await expect(this.page.locator(this.startAt)).toBeDisabled({ timeout: PAGE_TIMEOUT_MS });
}
async chooseBatching(kind) {
if ((await this.batchingItem(kind).getAttribute('data-state')) !== 'on') await this.batchingItem(kind).click();
await expect(this.batchingItem(kind)).toHaveAttribute('data-state', 'on');
}
async setCanaryFirst(value) {
await this.page.locator(this.firstField).fill(String(value));
}
async setProgressive(initial, max) {
await this.page.locator(this.initialField).fill(String(initial));
await this.page.locator(this.maxField).fill(String(max));
}
async expectFirstError(text) {
await expect(this.page.locator(this.firstError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
async expectMaxError(text) {
await expect(this.page.locator(this.maxError)).toHaveText(text, { timeout: PAGE_TIMEOUT_MS });
}
}
module.exports = FleetRolloutPage;
@@ -0,0 +1,392 @@
const crypto = require('crypto');
const { expect } = require('@playwright/test');
const testLogger = require('../../playwright-tests/utils/test-logger.js');
const { getAuthHeaders, getOrgIdentifier } = require('../../playwright-tests/utils/cloud-auth.js');
const FLEET_PREFIX = 'fl_auto_';
const CONFIG_PREFIX = 'fl-auto-';
const LIST_PAGE = 'offset=0&limit=1000';
const FLEET_YAML = {
seed: 'service:\n pipelines:\n logs: {}\n',
publishable: [
'receivers:',
' otlp:',
' protocols:',
' http:',
' endpoint: "0.0.0.0:4318"',
'exporters:',
' otlphttp:',
' endpoint: "https://example.com"',
'service:',
' pipelines:',
' logs:',
' receivers: [otlp]',
' exporters: [otlphttp]',
'',
].join('\n'),
broken: 'receivers:\n otlp: [unclosed\n',
dottedKey: [
'processors:',
' resourcedetection:',
' detectors:',
' - system',
' system:',
' resource_attributes:',
' host.id:',
' enabled: true',
'exporters:',
' debug:',
' verbosity: basic',
'service:',
' pipelines:',
' logs:',
' processors: [resourcedetection]',
' exporters: [debug]',
'',
].join('\n'),
quotedNumber: [
'exporters:',
' debug:',
' verbosity: "123"',
'service:',
' pipelines:',
' logs:',
' exporters: [debug]',
'',
].join('\n'),
paletteResult: [
'receivers:',
' otlp:',
' protocols:',
' grpc:',
' endpoint: "0.0.0.0:4317"',
' http:',
' endpoint: "0.0.0.0:4318"',
'processors:',
' batch:',
' timeout: 5s',
' send_batch_size: 8192',
' memory_limiter:',
' check_interval: 1s',
' limit_mib: 400',
' spike_limit_mib: 100',
'exporters:',
' otlphttp/openobserve:',
' endpoint: "${env:O2_ENDPOINT}"',
' headers:',
' Authorization: "${env:O2_TOKEN}"',
' stream-name: default',
'service:',
' pipelines:',
' logs:',
' receivers: [otlp]',
' processors: [memory_limiter, batch]',
' exporters: [otlphttp/openobserve]',
'',
].join('\n'),
};
class FleetSeed {
constructor(page) {
this.page = page;
this.run = crypto.randomUUID().replace(/-/g, '').slice(0, 8);
this.fleetPrefix = `${FLEET_PREFIX}${this.run}_`;
this.configPrefix = `${CONFIG_PREFIX}${this.run}-`;
this.loginUserField = '[data-test="login-user-id-field"]';
this.loginPasswordField = '[data-test="login-password-field"]';
this.loginSubmit = '[data-test="login-sign-in"]';
this.loginInternalUser = '[data-test="login-as-internal-user"]';
this.appShell = '[data-test="navbar-main-nav"]';
}
// beforeAll and afterAll run on different raw pages, so the run prefix must outlive the first one.
setPage(page) {
this.page = page;
}
fleetName(suffix) {
return `${this.fleetPrefix}${suffix}`;
}
configId(suffix) {
return `${this.configPrefix}${suffix}`;
}
orgId() {
return getOrgIdentifier();
}
apiUrl(path) {
return `${process.env.ZO_BASE_URL.replace(/\/$/, '')}/api/${getOrgIdentifier()}${path}`;
}
async call(method, path, data) {
const resp = await this.page.request.fetch(this.apiUrl(path), {
method,
headers: getAuthHeaders(),
...(data === undefined ? {} : { data }),
});
const text = await resp.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
body = text;
}
return { status: resp.status(), body };
}
async ok(method, path, data, what) {
const res = await this.call(method, path, data);
if (res.status >= 400) {
throw new Error(`${what} failed: ${method} ${path} -> ${res.status} ${JSON.stringify(res.body).slice(0, 300)}`);
}
return res.body;
}
async createFleet(suffix) {
const name = this.fleetName(suffix);
const body = await this.ok('POST', '/fleet/fleets', { name }, `create fleet ${name}`);
if (!body?.id) throw new Error(`create fleet ${name}: no id in ${JSON.stringify(body)}`);
testLogger.info('Seeded fleet', { id: body.id, name });
return { id: body.id, name };
}
async createToken(fleetId, suffix) {
const name = this.fleetName(suffix);
const body = await this.ok(
'POST',
'/fleet/tokens',
{ name, expires_in_days: null, max_uses: null, fleet: fleetId },
`create token ${name}`,
);
if (!/^o2ft_/.test(body?.token ?? '')) throw new Error(`create token ${name}: no o2ft_ value returned`);
return { id: body.id, name, token: body.token };
}
async createConfig(suffix, yaml) {
const id = this.configId(suffix);
await this.ok('POST', '/fleet/configs', { id, description: 'fleet automation', yaml }, `create config ${id}`);
return id;
}
async publishConfig(id, yaml, message = 'fleet automation') {
const body = await this.ok('POST', `/fleet/configs/${id}/versions`, { yaml, message }, `publish ${id}`);
return body.version;
}
async getConfig(id) {
return await this.ok('GET', `/fleet/configs/${id}`, undefined, `get config ${id}`);
}
async setDesired(fleetId, config, version) {
const body = await this.ok(
'PUT',
`/fleet/fleets/${fleetId}/desired`,
{
config,
version,
start: { kind: 'now' },
batching: { kind: 'one' },
verify: false,
auto_rollback: false,
},
`deploy ${config} v${version} to ${fleetId}`,
);
return { id: body.id };
}
async waitForRolloutStatus(rolloutId, status) {
await expect
.poll(async () => (await this.call('GET', `/fleet/rollouts/${rolloutId}`)).body?.status, {
timeout: 30000,
message: `rollout ${rolloutId} never reached ${status}`,
})
.toBe(status);
}
async rolloutsForConfig(configId) {
const body = await this.ok('GET', `/fleet/rollouts?${LIST_PAGE}`, undefined, 'list rollouts');
return (body?.items ?? []).filter((r) => r.config === configId);
}
async listFleets() {
const body = await this.ok('GET', `/fleet/fleets?${LIST_PAGE}`, undefined, 'list fleets');
return body?.items ?? [];
}
async expectNoFleetNamed(name) {
const names = (await this.listFleets()).map((f) => f.name);
expect(names, `fleet ${name} exists although its create was refused`).not.toContain(name);
}
async targetCollectorVersion() {
const body = await this.ok('GET', '/fleet/settings', undefined, 'get fleet settings');
return body?.target_collector_version || null;
}
async ingestionBaseUrl() {
const config = await this.ok('GET', '/config', undefined, 'get org config');
return config?.ingestion_url || null;
}
async assertRbacEnabled() {
const { status } = await this.call('GET', '/roles');
if (status >= 400) {
throw new Error(`RBAC is off on ${process.env.ZO_BASE_URL} (GET /roles -> ${status}); the Fleet-RBAC shard must start OpenFGA.`);
}
}
async createRole(suffix, grants) {
const role = this.fleetName(suffix);
await this.ok('POST', '/roles', { role }, `create role ${role}`);
if (grants.length) {
await this.ok(
'PUT',
`/roles/${role}`,
{ add: grants, remove: [], add_users: [], remove_users: [] },
`grant ${role}`,
);
}
return role;
}
async createMember(suffix, role = null) {
const email = `${this.fleetName(suffix)}_${crypto.randomUUID().slice(0, 8)}@example.com`;
const password = `Fl#9${crypto.randomBytes(12).toString('base64url')}aZ`;
await this.ok(
'POST',
'/users',
{ email, password, first_name: 'Automation', last_name: 'TestUser', role: 'user' },
`create member ${email}`,
);
if (role) {
await this.ok(
'PUT',
`/roles/${role}`,
{ add: [], remove: [], add_users: [email], remove_users: [] },
`assign ${role} to ${email}`,
);
}
return { email, password };
}
fleetGrant(object, permission) {
return { object: `fleets:${object}`, permission };
}
orgGrant(type, permission) {
return { object: `${type}:_all_${getOrgIdentifier()}`, permission };
}
async loginAsMember(browser, member) {
const context = await browser.newContext({
viewport: { width: 1500, height: 1024 },
permissions: ['clipboard-read', 'clipboard-write'],
});
const page = await context.newPage();
await page.goto(`${process.env.ZO_BASE_URL}/web/?org_identifier=${getOrgIdentifier()}`, {
waitUntil: 'domcontentloaded',
});
const userField = page.locator(this.loginUserField);
const internal = page.locator(this.loginInternalUser).first();
await expect(userField.or(internal)).toBeVisible({ timeout: 30000 });
if (await internal.isVisible()) await internal.click();
await userField.fill(member.email);
await page.locator(this.loginPasswordField).fill(member.password);
await page.locator(this.loginSubmit).click();
await expect(page.locator(this.appShell)).toBeVisible({ timeout: 30000 });
testLogger.info('Signed in as fleet member', { email: member.email });
return { context, page };
}
// Grants reach OpenFGA asynchronously, so a member's first read must see them before the UI is judged.
async waitForMemberStatus(memberPage, path, status) {
await expect
.poll(
async () =>
(
await memberPage.request.fetch(this.apiUrl(path), {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})
).status(),
{ timeout: 30000, message: `member GET ${path} never answered ${status}` },
)
.toBe(status);
}
async cleanup() {
const removed = { tokens: 0, fleets: 0, configs: 0, roles: 0, users: 0 };
const mine = async (path, key) => {
const res = await this.call('GET', path);
if (res.status >= 400) return [];
const items = Array.isArray(res.body) ? res.body : (res.body?.items ?? res.body?.data ?? []);
return items.filter((x) => typeof key(x) === 'string');
};
const fleets = (await mine(`/fleet/fleets?${LIST_PAGE}`, (f) => f?.name)).filter((f) =>
f.name.startsWith(this.fleetPrefix),
);
const fleetIds = new Set(fleets.map((f) => f.id));
for (const tok of await mine(`/fleet/tokens?${LIST_PAGE}`, (t) => t?.id)) {
if (fleetIds.has(tok.fleet?.id) && !tok.revoked) {
await this.call('POST', `/fleet/tokens/${tok.id}/revoke`);
removed.tokens += 1;
}
}
for (const f of fleets) {
await this.call('DELETE', `/fleet/fleets/${f.id}`);
removed.fleets += 1;
}
for (const c of await mine(`/fleet/configs?${LIST_PAGE}`, (c) => c?.id)) {
if (c.id.startsWith(this.configPrefix)) {
await this.call('DELETE', `/fleet/configs/${c.id}`);
removed.configs += 1;
}
}
for (const u of await mine('/users', (u) => u?.email)) {
if (u.email.startsWith(this.fleetPrefix)) {
await this.call('DELETE', `/users/${u.email}`);
removed.users += 1;
}
}
const roles = await this.call('GET', '/roles');
for (const r of roles.status < 400 && Array.isArray(roles.body) ? roles.body : []) {
if (typeof r === 'string' && r.startsWith(this.fleetPrefix)) {
await this.call('DELETE', `/roles/${r}`);
removed.roles += 1;
}
}
testLogger.info('Fleet teardown', { run: this.run, ...removed });
return removed;
}
// Anything still carrying this run's prefix after cleanup() is a leak; a failed list read fails loudly.
async survivors() {
const left = [];
for (const f of await this.listFleets()) {
if (f.name?.startsWith(this.fleetPrefix)) left.push(`fleet ${f.name}`);
}
const configs = await this.ok('GET', `/fleet/configs?${LIST_PAGE}`, undefined, 'list configs');
for (const c of configs?.items ?? []) {
if (c.id?.startsWith(this.configPrefix)) left.push(`config ${c.id}`);
}
const roles = await this.call('GET', '/roles');
if (roles.status < 400) {
for (const r of Array.isArray(roles.body) ? roles.body : []) {
if (typeof r === 'string' && r.startsWith(this.fleetPrefix)) left.push(`role ${r}`);
}
}
const users = await this.ok('GET', '/users', undefined, 'list users');
for (const u of users?.data ?? []) {
if (u?.email?.startsWith(this.fleetPrefix)) left.push(`user ${u.email}`);
}
return left;
}
}
module.exports = FleetSeed;
module.exports.FLEET_YAML = FLEET_YAML;
module.exports.FLEET_PREFIX = FLEET_PREFIX;
module.exports.CONFIG_PREFIX = CONFIG_PREFIX;
@@ -193,6 +193,17 @@ export class RolesPage {
return [...new Set(slugs.map((s) => s.replace(RAIL_ITEM, '')))];
}
/** Rail module key → its label, read without the granted-count badge. */
async railModuleLabels() {
const rows = await this.page
.locator(`[data-test^="${RAIL_ITEM}"]`)
.evaluateAll((els) => els.map((e) => [
e.getAttribute('data-test'),
(e.querySelector('.o-tab__label')?.textContent ?? '').trim(),
]));
return Object.fromEntries(rows.map(([slug, label]) => [slug.replace(RAIL_ITEM, ''), label]));
}
/**
* Resource keys of the rows the pane is CURRENTLY showing — after any search or
* filter. On the All Modules view a row is a whole module, so this is the set one
+14
View File
@@ -145,6 +145,12 @@ const SchemaLoadPage = require("./generalPages/schemaLoadPage.js");
const APICleanup = require("./apiCleanup.js");
const WorkflowsPage = require("./workflowsPages/workflowsPage.js");
const WorkflowFoldersPage = require("./workflowsPages/workflowFoldersPage.js");
const FleetManagerPage = require("./fleetPages/fleetManagerPage.js");
const FleetFleetsPage = require("./fleetPages/fleetFleetsPage.js");
const FleetInstallPage = require("./fleetPages/fleetInstallPage.js");
const FleetConfigPage = require("./fleetPages/fleetConfigPage.js");
const FleetRolloutPage = require("./fleetPages/fleetRolloutPage.js");
const FleetSeed = require("./fleetPages/fleetSeed.js");
// ===== LOGS, REPORTS, STREAMS, PIPELINES ADDITIONAL PAGE OBJECTS =====
import { LogsQueryPage } from "./logsPages/logsQueryPage.js";
@@ -261,6 +267,14 @@ class PageManager {
this.workflowsPage = new WorkflowsPage(page);
this.workflowFoldersPage = new WorkflowFoldersPage(page);
// ===== FLEET MANAGEMENT PAGE OBJECTS =====
this.fleetManagerPage = new FleetManagerPage(page);
this.fleetFleetsPage = new FleetFleetsPage(page);
this.fleetInstallPage = new FleetInstallPage(page);
this.fleetConfigPage = new FleetConfigPage(page);
this.fleetRolloutPage = new FleetRolloutPage(page);
this.fleetSeed = new FleetSeed(page);
// ===== SANITY SPEC ADDITIONAL PAGE OBJECTS =====
this.logsPage = new LogsPage(page);
this.logsToolbarPinsPage = new LogsToolbarPinsPage(page);
@@ -0,0 +1,161 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
test.describe('Fleet management access', { tag: ['@fleet', '@enterprise', '@all'] }, () => {
// Serial: the roles, users and fleets are made once in beforeAll and shared, as in IAM/iam-roles-enforcement.spec.js.
test.describe.configure({ mode: 'serial' });
let pm;
let seed;
let fleetA;
let fleetB;
const members = {};
test.beforeAll(async ({ browser }) => {
const page = await browser.newPage();
try {
const setup = new PageManager(page);
await setup.fleetManagerPage.assertEnabled();
seed = setup.fleetSeed;
await seed.assertRbacEnabled();
fleetA = await seed.createFleet('a');
fleetB = await seed.createFleet('b');
const reader = await seed.createRole('reader', [
seed.orgGrant('fleets', 'AllowList'),
seed.orgGrant('fleets', 'AllowGet'),
seed.orgGrant('fleet_configs', 'AllowGet'),
seed.orgGrant('fleet_management', 'AllowGet'),
]);
const aWriter = await seed.createRole('a_writer', [seed.fleetGrant(fleetA.id, 'AllowAll')]);
const listOnly = await seed.createRole('list_only', [seed.orgGrant('fleets', 'AllowList')]);
members.none = await seed.createMember('none');
members.reader = await seed.createMember('reader', reader);
members.aWriter = await seed.createMember('a_writer', aWriter);
members.listOnly = await seed.createMember('list_only', listOnly);
testLogger.info('Fleet access fixtures ready', { fleetA: fleetA.id, fleetB: fleetB.id });
} finally {
await page.close();
}
});
test.afterAll(async ({ browser }) => {
if (!seed) return;
const page = await browser.newPage();
try {
seed.setPage(page);
await seed.cleanup();
expect(await seed.survivors()).toEqual([]);
} finally {
await page.close();
}
});
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
await navigateToBase(page);
pm = new PageManager(page);
// The beforeAll page that seeded the fixtures is closed, so seed API calls need this test's live page.
seed.setPage(page);
});
test('FL-RBAC-02 + R-16105a · a member without fleet grants gets the no-access states and no pipelines error banner', {
tag: ['@fleetAccess', '@regression', '@P0'],
}, async ({ browser }) => {
const member = await seed.loginAsMember(browser, members.none);
try {
const mpm = new PageManager(member.page);
await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets' });
await mpm.fleetManagerPage.expectFleetNoAccess();
await mpm.fleetManagerPage.expectCollectorsForbidden();
await mpm.fleetManagerPage.clickTab('overview');
await mpm.fleetManagerPage.expectOverviewNoFleetsWithoutErrorBanner();
} finally {
await member.context.close();
}
});
test('FL-RBAC-03 + R-16105b · a reader clicking New fleet gets "You don\'t have permission to do this" and nothing is created', {
tag: ['@fleetAccess', '@regression', '@P0'],
}, async ({ browser }) => {
const member = await seed.loginAsMember(browser, members.reader);
try {
await seed.waitForMemberStatus(member.page, `/fleet/fleets/${fleetA.id}`, 200);
const mpm = new PageManager(member.page);
const refused = seed.fleetName('refused');
await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id });
await mpm.fleetFleetsPage.expectPanelFor(fleetA.name);
await mpm.fleetManagerPage.clickCreate('fleets');
await mpm.fleetFleetsPage.submitNewFleet(refused);
await mpm.fleetManagerPage.expectErrorToast("You don't have permission to do this");
await seed.expectNoFleetNamed(refused);
} finally {
await member.context.close();
}
});
test('R-3232-ui · a writer of fleet A only can open the Fleets tab and read fleet A, not fleet B', {
tag: ['@fleetAccess', '@regression', '@P0'],
}, async ({ browser }) => {
const member = await seed.loginAsMember(browser, members.aWriter);
try {
await seed.waitForMemberStatus(member.page, `/fleet/fleets/${fleetA.id}`, 200);
const mpm = new PageManager(member.page);
await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id });
await mpm.fleetFleetsPage.expectPanelFor(fleetA.name);
await mpm.fleetManagerPage.expectCollectorsNotForbidden();
await mpm.fleetFleetsPage.expectRailItemReadable(fleetA.id, true);
await mpm.fleetFleetsPage.expectRailItemReadable(fleetB.id, false);
await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetB.id });
await mpm.fleetManagerPage.expectFleetNoAccess();
} finally {
await member.context.close();
}
});
test('FL-RBAC-08 · a member without GET on a fleet sees "You can see this fleet, but not its collectors or versions"', {
tag: ['@fleetAccess', '@P2'],
}, async ({ browser }) => {
// With O2_OPENFGA_LIST_ONLY_PERMITTED off (this shard), LIST adds nothing over no grant; this proves the no-GET panel only.
const member = await seed.loginAsMember(browser, members.listOnly);
try {
const mpm = new PageManager(member.page);
await mpm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id });
await mpm.fleetManagerPage.expectFleetNoAccess('You can see this fleet, but not its collectors or versions');
await mpm.fleetFleetsPage.expectRailItemReadable(fleetA.id, false);
} finally {
await member.context.close();
}
});
test('R-16104c · the Settings tab has no "Alerts for fleets" section', {
tag: ['@fleetAccess', '@regression', '@P2'],
}, async () => {
await pm.fleetManagerPage.gotoFleet({ tab: 'settings' });
await pm.fleetManagerPage.expectTabActive('settings');
await pm.fleetManagerPage.expectSettingsWithoutFleetAlerts(['Alerts for fleets', 'Open the alert library']);
});
test('R-16105c · collectors "Reset to default" keeps Architecture and Last connected from hidden', {
tag: ['@fleetAccess', '@regression', '@P3'],
}, async () => {
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id });
await pm.fleetFleetsPage.expectPanelFor(fleetA.name);
await pm.fleetManagerPage.expectColumnChecked('arch', false);
await pm.fleetManagerPage.expectColumnChecked('lastConnectedFrom', false);
await pm.fleetManagerPage.toggleColumn('os');
await pm.fleetManagerPage.expectColumnChecked('os', false);
await pm.fleetManagerPage.expectColumnShown('os', false);
await pm.fleetManagerPage.resetColumns();
await pm.fleetManagerPage.expectColumnChecked('os', true);
await pm.fleetManagerPage.expectColumnChecked('arch', false);
await pm.fleetManagerPage.expectColumnChecked('lastConnectedFrom', false);
await pm.fleetManagerPage.expectColumnShown('os', true);
await pm.fleetManagerPage.expectColumnShown('arch', false);
await pm.fleetManagerPage.expectColumnShown('lastConnectedFrom', false);
});
});
@@ -0,0 +1,174 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const { FLEET_YAML } = require('../../pages/fleetPages/fleetSeed.js');
const DOTTED_KEY = 'system.resource_attributes."host.id".enabled';
test.describe('Fleet management configurations and deploy', { tag: ['@fleet', '@enterprise', '@all'] }, () => {
test.describe.configure({ mode: 'parallel' });
let pm;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
// Cleared first so a failed navigation never leaves afterEach the previous test's closed page.
pm = undefined;
await navigateToBase(page);
pm = new PageManager(page);
await pm.fleetManagerPage.assertEnabled();
});
test.afterEach(async () => {
if (!pm) return;
await pm.fleetSeed.cleanup();
expect(await pm.fleetSeed.survivors()).toEqual([]);
});
test('FL-CFG-03 + FL-CFG-08 · palette OTLP, Batch, OpenObserve and Memory limiter wire a pipeline with memory_limiter first and env references', {
tag: ['@fleetConfig', '@smoke', '@P0'],
}, async () => {
const config = await pm.fleetSeed.createConfig('palette', FLEET_YAML.seed);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.expectVisualMode();
await pm.fleetConfigPage.addFromPalette('receiver', 'otlp', 'logs|receiver:otlp');
await pm.fleetConfigPage.addFromPalette('processor', 'batch', 'logs|processor:batch');
await pm.fleetConfigPage.addFromPalette('exporter', 'openobserve', 'logs|exporter:otlphttp/openobserve');
await pm.fleetConfigPage.addFromPalette('processor', 'memory_limiter', 'logs|processor:memory_limiter');
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.expectYaml(FLEET_YAML.paletteResult);
const yaml = await pm.fleetConfigPage.readYaml();
expect(yaml).toContain('processors: [memory_limiter, batch]');
expect(yaml).toContain('endpoint: "${env:O2_ENDPOINT}"');
expect(yaml).toContain('Authorization: "${env:O2_TOKEN}"');
});
test('FL-CFG-04 + FL-CFG-05 · a YAML edit round-trips through Visual byte for byte and a dotted key stays one key', {
tag: ['@fleetConfig', '@regression', '@P1'],
}, async () => {
const config = await pm.fleetSeed.createConfig('roundtrip', FLEET_YAML.seed);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.setYaml(FLEET_YAML.dottedKey);
await pm.fleetConfigPage.switchToVisual();
await pm.fleetConfigPage.selectNode('logs|processor:resourcedetection');
await pm.fleetConfigPage.expectPropertyValue(DOTTED_KEY, 'true');
await pm.fleetConfigPage.expectNoProperty('system.resource_attributes.host.id.enabled');
await pm.fleetConfigPage.setProperty(DOTTED_KEY, 'false');
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.expectYaml(FLEET_YAML.dottedKey.replace('enabled: true', 'enabled: false'));
});
test('R-16104a · Visual keeps "true", "123" and "null" as strings, and YAML with a quoted number opens in Visual', {
tag: ['@fleetConfig', '@regression', '@P1'],
}, async () => {
const config = await pm.fleetSeed.createConfig('quoted', FLEET_YAML.seed);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.setYaml(FLEET_YAML.quotedNumber);
await pm.fleetConfigPage.switchToVisual();
await pm.fleetConfigPage.selectNode('logs|exporter:debug');
await pm.fleetConfigPage.expectPropertyValue('verbosity', '"123"');
await pm.fleetConfigPage.addSetting('fl_true', '"true"');
await pm.fleetConfigPage.addSetting('fl_num', '"123"');
await pm.fleetConfigPage.addSetting('fl_null', '"null"');
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.expectYaml(
FLEET_YAML.quotedNumber.replace(
' verbosity: "123"\n',
' verbosity: "123"\n fl_true: "true"\n fl_num: "123"\n fl_null: "null"\n',
),
);
});
test('FL-CFG-09 · invalid YAML validates as "1 error · publish blocked" and Publish is refused', {
tag: ['@fleetConfig', '@smoke', '@P0'],
}, async () => {
const config = await pm.fleetSeed.createConfig('invalid', FLEET_YAML.seed);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.setYaml(FLEET_YAML.broken);
await pm.fleetConfigPage.expectVisualUnavailable();
await pm.fleetConfigPage.validate();
await pm.fleetConfigPage.expectValidationChip('1 error · publish blocked');
await pm.fleetConfigPage.publish('fleet automation invalid');
await pm.fleetManagerPage.expectAnyErrorToast();
await pm.fleetConfigPage.expectModeChip('Editing draft');
expect((await pm.fleetSeed.getConfig(config)).latest_version).toBe(0);
});
test('FL-VER-01 + FL-VER-02 · publishing reads "published v1 · not rolled out"; after v2 Compare shows the diff and is off for v1', {
tag: ['@fleetConfig', '@P1'],
}, async () => {
const config = await pm.fleetSeed.createConfig('versions', FLEET_YAML.publishable);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.expectModeChip('Editing draft');
await pm.fleetConfigPage.publish('first version');
await pm.fleetConfigPage.expectModeChip('Viewing published v1 · not rolled out');
await pm.fleetConfigPage.expectSuccessToast('Published v1');
await pm.fleetConfigPage.switchToYaml();
await pm.fleetConfigPage.setYaml(FLEET_YAML.publishable.replace('https://example.com', 'https://example.org'));
await pm.fleetConfigPage.expectModeChip('Editing draft');
await pm.fleetConfigPage.publish('second version');
await pm.fleetConfigPage.expectModeChip('Viewing published v2 · not rolled out');
await pm.fleetConfigPage.openHistory();
await pm.fleetConfigPage.expectCompareDisabled(1);
await pm.fleetConfigPage.compare(2);
await pm.fleetConfigPage.expectDiffContains('https://example.org');
expect((await pm.fleetSeed.getConfig(config)).latest_version).toBe(2);
});
test('R-16104b · Deploy from the editor opens with no fleet picked and is blocked with "Pick a fleet"', {
tag: ['@fleetDeploy', '@regression', '@P1'],
}, async () => {
await pm.fleetSeed.createFleet('pick');
const config = await pm.fleetSeed.createConfig('deploy', FLEET_YAML.publishable);
await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.openDeployFromEditor();
await pm.fleetRolloutPage.expectReady();
await pm.fleetRolloutPage.expectNoFleetPicked();
await pm.fleetRolloutPage.submit();
await pm.fleetRolloutPage.expectFleetError('Pick a fleet');
expect(await pm.fleetSeed.rolloutsForConfig(config)).toEqual([]);
});
test('FL-RO-01 · the deploy dialog disables Scheduled for an empty fleet and refuses counts below 1 and a max below the initial batch', {
tag: ['@fleetDeploy', '@P1'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet('guards');
const config = await pm.fleetSeed.createConfig('guards', FLEET_YAML.publishable);
const v1 = await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable);
const seeded = await pm.fleetSeed.setDesired(fleet.id, config, v1);
await pm.fleetSeed.waitForRolloutStatus(seeded.id, 'completed');
// A desired v1 plus a published v2 seeds a valid config and version, so only the batching fields are judged.
await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable.replace('https://example.com', 'https://example.org'));
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openDeploy();
await pm.fleetRolloutPage.expectReady();
await pm.fleetRolloutPage.expectScheduledDisabled();
await pm.fleetRolloutPage.chooseBatching('canary');
await pm.fleetRolloutPage.setCanaryFirst(0);
await pm.fleetRolloutPage.submit();
await pm.fleetRolloutPage.expectFirstError('Enter a whole number of 1 or more');
await pm.fleetRolloutPage.chooseBatching('progressive');
await pm.fleetRolloutPage.setProgressive(5, 2);
await pm.fleetRolloutPage.submit();
await pm.fleetRolloutPage.expectMaxError('Use at least the initial batch');
expect((await pm.fleetSeed.rolloutsForConfig(config)).map((r) => r.id)).toEqual([seeded.id]);
});
});
@@ -0,0 +1,151 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const FleetInstallPage = require('../../pages/fleetPages/fleetInstallPage.js');
const PLATFORMS = [
{ id: 'FL-INST-03', label: 'Linux', platform: 'linux' },
{ id: 'FL-INST-04', label: 'macOS', platform: 'macos' },
{ id: 'FL-INST-05', label: 'Windows', platform: 'windows' },
{ id: 'FL-INST-06', label: 'Docker', platform: 'docker' },
{ id: 'FL-INST-07', label: 'Kubernetes agent', platform: 'kubernetes', role: 'agent' },
{ id: 'FL-INST-08', label: 'Kubernetes gateway', platform: 'kubernetes', role: 'gateway' },
];
test.describe('Fleet management Add collector', { tag: ['@fleet', '@enterprise', '@all'] }, () => {
test.describe.configure({ mode: 'parallel' });
let pm;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
// Cleared first so a failed navigation never leaves afterEach the previous test's closed page.
pm = undefined;
await navigateToBase(page);
pm = new PageManager(page);
await pm.fleetManagerPage.assertEnabled();
});
test.afterEach(async () => {
if (!pm) return;
await pm.fleetSeed.cleanup();
expect(await pm.fleetSeed.survivors()).toEqual([]);
});
for (const p of PLATFORMS) {
test(`${p.id} + FL-INST-09 + FL-UNI-02 · ${p.label}: every install line over HTTP and WebSocket, uninstall without the token`, {
tag: ['@fleetInstall', '@regression', '@P0'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet(`inst_${p.platform}_${p.role ?? 'host'}`);
const token = await pm.fleetSeed.createToken(fleet.id, 'tok');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openAddCollector();
await pm.fleetInstallPage.expectDialogOpen();
await pm.fleetInstallPage.expectCommandShown();
await pm.fleetInstallPage.selectPlatform(p.platform);
if (p.role) await pm.fleetInstallPage.selectRole(p.role);
const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), role: p.role };
await pm.fleetInstallPage.revealToken();
await pm.fleetInstallPage.expectWebsocket(false);
await pm.fleetInstallPage.expectCommandLines(
FleetInstallPage.expectedInstallLines(p.platform, { ...target, http: true }),
);
await pm.fleetInstallPage.setWebsocket(true);
await pm.fleetInstallPage.expectCommandLines(
FleetInstallPage.expectedInstallLines(p.platform, { ...target, http: false }),
);
await pm.fleetInstallPage.expandUninstall();
await pm.fleetInstallPage.expectUninstallLines(
FleetInstallPage.expectedUninstallLines(p.platform, p.role),
token.token,
);
});
}
test('R-16042 · a Kubernetes cluster name becomes --set-string clusterName with commas escaped; empty adds no flag', {
tag: ['@fleetInstall', '@regression', '@P2'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet('cluster');
const token = await pm.fleetSeed.createToken(fleet.id, 'tok');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openAddCollector();
await pm.fleetInstallPage.expectCommandShown();
await pm.fleetInstallPage.selectPlatform('kubernetes');
const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), http: true, role: 'agent' };
await pm.fleetInstallPage.revealToken();
await pm.fleetInstallPage.setClusterName('prod,us-east');
const withName = FleetInstallPage.expectedInstallLines('kubernetes', { ...target, clusterName: 'prod,us-east' });
expect(withName).toContain(" --set-string clusterName='prod\\,us-east' \\");
await pm.fleetInstallPage.expectCommandLines(withName);
await pm.fleetInstallPage.setClusterName('');
const withoutName = FleetInstallPage.expectedInstallLines('kubernetes', target);
expect(withoutName.some((line) => line.includes('clusterName'))).toBe(false);
await pm.fleetInstallPage.expectCommandLines(withoutName);
});
test('FL-INST-10 · the token stays masked until Reveal, and Copy gives the full command with the real token', {
tag: ['@fleetInstall', '@P1'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet('mask');
const token = await pm.fleetSeed.createToken(fleet.id, 'tok');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openAddCollector();
await pm.fleetInstallPage.expectCommandShown();
const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, token.token)), http: true };
await pm.fleetInstallPage.expectCommandMasked(token.token);
const copied = await pm.fleetInstallPage.copyCommand();
expect(copied.split('\n')).toEqual(FleetInstallPage.expectedInstallLines('linux', target));
await pm.fleetInstallPage.revealToken();
await pm.fleetInstallPage.expectCommandLines(FleetInstallPage.expectedInstallLines('linux', target));
});
test('FL-INST-01 + R-16103 · with no token the inline form creates one, shows the command and polls collectors again', {
tag: ['@fleetInstall', '@regression', '@P0'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet('notoken');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openAddCollector();
await pm.fleetInstallPage.expectNoTokenState();
const created = await pm.fleetInstallPage.createInlineTokenAndAwaitCollectorsPoll(pm.fleetSeed.fleetName('inline'));
expect(created.token).toMatch(/^o2ft_/);
expect(created.fleet?.id).toBe(fleet.id);
await pm.fleetInstallPage.expectCommandShown();
await pm.fleetInstallPage.expectCopyOnceAndWaiting();
await pm.fleetInstallPage.expectCommandMasked(created.token);
});
test('FL-INST-02 + FL-INST-11 · the token select lists "{name} (no collectors)" newest first, says which fleet it joins, and --url is the ingestion URL', {
tag: ['@fleetInstall', '@P1'],
}, async () => {
const fleet = await pm.fleetSeed.createFleet('pick');
const older = await pm.fleetSeed.createToken(fleet.id, 'older');
const newer = await pm.fleetSeed.createToken(fleet.id, 'newer');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleet.id });
await pm.fleetFleetsPage.openAddCollector();
await pm.fleetInstallPage.expectCommandShown();
await pm.fleetInstallPage.expectSelectedToken(`${newer.name} (no collectors)`);
expect(await pm.fleetInstallPage.tokenOptionLabels()).toEqual([
`${newer.name} (no collectors)`,
`${older.name} (no collectors)`,
]);
await pm.fleetInstallPage.expectJoinsText(
`Collectors installed with this token join fleet ${fleet.name}, which has no configuration yet.`,
);
const target = { ...(await pm.fleetInstallPage.installTarget(pm.fleetSeed, newer.token)), http: true };
await pm.fleetInstallPage.revealToken();
const lines = FleetInstallPage.expectedInstallLines('linux', target);
await pm.fleetInstallPage.expectCommandLines(lines);
});
});
@@ -0,0 +1,94 @@
const { test, expect, navigateToBase } = require('../utils/enhanced-baseFixtures.js');
const testLogger = require('../utils/test-logger.js');
const PageManager = require('../../pages/page-manager.js');
const { FLEET_YAML } = require('../../pages/fleetPages/fleetSeed.js');
test.describe('Fleet management navigation', { tag: ['@fleet', '@enterprise', '@all'] }, () => {
test.describe.configure({ mode: 'parallel' });
let pm;
test.beforeEach(async ({ page }, testInfo) => {
testLogger.testStart(testInfo.title, testInfo.file);
// Cleared first so a failed navigation never leaves afterEach the previous test's closed page.
pm = undefined;
await navigateToBase(page);
pm = new PageManager(page);
await pm.fleetManagerPage.assertEnabled();
});
test.afterEach(async () => {
if (!pm) return;
await pm.fleetSeed.cleanup();
expect(await pm.fleetSeed.survivors()).toEqual([]);
});
test('FL-GATE-01 · Data → Fleet management opens /web/fleet on the Overview', {
tag: ['@fleetNav', '@smoke', '@P0'],
}, async () => {
await pm.fleetManagerPage.openFromNav();
await pm.fleetManagerPage.expectOnFleetRoute({ org_identifier: pm.fleetSeed.orgId() });
await pm.fleetManagerPage.expectOverviewVisible();
});
test('FL-NAV-01 · tabs set ?tab= by replace and keep org_identifier, so Back leaves the page', {
tag: ['@fleetNav', '@P1'],
}, async () => {
const org = pm.fleetSeed.orgId();
await pm.fleetManagerPage.gotoFleet();
await pm.fleetManagerPage.expectTabActive('overview');
await pm.fleetManagerPage.clickTab('fleets');
await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'fleets', org_identifier: org });
await pm.fleetManagerPage.clickTab('configurations');
await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'configurations', org_identifier: org });
await pm.fleetManagerPage.clickTab('audit');
await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'audit', org_identifier: org });
await pm.fleetManagerPage.goBackAndExpectLeftFleet();
});
test('FL-NAV-04 · an unknown ?fleet= says the fleet no longer exists', {
tag: ['@fleetNav', '@P2'],
}, async () => {
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: pm.fleetSeed.fleetName('missing') });
await pm.fleetManagerPage.expectFleetNotFound('This fleet no longer exists');
});
test('FL-NAV-05 · ?scope=all&status=offline shows the Fleet column, Offline pressed and no fleet panel', {
tag: ['@fleetNav', '@P1'],
}, async () => {
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', scope: 'all', status: 'offline' });
await pm.fleetManagerPage.expectAllFleetsScope();
await pm.fleetManagerPage.expectStatPressed('offline');
await pm.fleetManagerPage.expectFleetPanelHidden();
});
test('FL-NAV-08 · ?rollout= of fleet B with ?fleet=A opens that rollout and switches to fleet B', {
tag: ['@fleetNav', '@P1'],
}, async () => {
const fleetA = await pm.fleetSeed.createFleet('a');
const fleetB = await pm.fleetSeed.createFleet('b');
const config = await pm.fleetSeed.createConfig('nav', FLEET_YAML.publishable);
const version = await pm.fleetSeed.publishConfig(config, FLEET_YAML.publishable);
const rollout = await pm.fleetSeed.setDesired(fleetB.id, config, version);
await pm.fleetSeed.waitForRolloutStatus(rollout.id, 'completed');
await pm.fleetManagerPage.gotoFleet({ tab: 'fleets', fleet: fleetA.id, rollout: rollout.id });
await pm.fleetManagerPage.expectQueryParam('fleet', fleetB.id);
await pm.fleetFleetsPage.expectRailItemActive(fleetB.id);
await pm.fleetFleetsPage.expectRolloutDrawerStatus('No collectors targeted');
});
test('FL-NAV-10 · the config editor deep link opens the editor and Back returns to ?tab=configurations', {
tag: ['@fleetNav', '@P2'],
}, async () => {
const config = await pm.fleetSeed.createConfig('deeplink', FLEET_YAML.seed);
await pm.fleetConfigPage.goto(config);
await pm.fleetConfigPage.expectTitle(config);
await pm.fleetConfigPage.clickBack();
await pm.fleetManagerPage.expectOnFleetRoute({ tab: 'configurations', org_identifier: pm.fleetSeed.orgId() });
await pm.fleetManagerPage.expectTabActive('configurations');
});
});
@@ -373,4 +373,19 @@ test.describe('IAM · Edit Role · navigation and filtering', { tag: '@enterpris
expect(pairs.length, 'JSON view rendered no grants').toBeGreaterThan(0);
expect(pairs).toEqual(stored.map((p) => `${p.object}|${p.permission}`).sort());
});
// FL-RBAC-01 · guards openobserve#15949: the no-op "Fleet Manager" permission must not return.
test('U-23 · the Fleet group lists Fleets, Fleet configurations and Fleet management, and no Fleet Manager', {
tag: ['@iam', '@iamRolesEditor', '@fleet', '@regression', '@P0', '@all']
}, async () => {
await pm.fleetManagerPage.assertEnabled();
const labels = await pm.rolesPage.railModuleLabels();
expect(labels).toMatchObject({
fleets: 'Fleets',
fleet_configs: 'Fleet configurations',
fleet_management: 'Fleet management',
});
expect(Object.keys(labels)).not.toContain('fleet');
expect(Object.values(labels)).not.toContain('Fleet Manager');
});
});
@@ -494,6 +494,9 @@ test.describe("Pre-Test Cleanup", () => {
// quietly rather than failing the cleanup every other suite depends on.
await pm.apiCleanup.cleanupIamArtifacts('ui_auto');
// Fleet management leftovers (Fleet/*.spec.js); enterprise-only, quiet on OSS.
await pm.apiCleanup.cleanupFleetArtifacts(['fl_auto_'], ['fl-auto-']);
testLogger.info('Pre-test cleanup completed successfully');
});
});
+15 -1
View File
@@ -12,6 +12,7 @@ usage() {
Usage: $0 [-h | --help] [<command>]
Commands:
merge Merge sharded blob reports and verify thresholds
check Run frontend tests with coverage and verify thresholds
html Generate coverage report in HTML format
show-env Show the coverage thresholds
@@ -50,7 +51,16 @@ EOF
cmd_check() {
echo "Running frontend tests with coverage..."
npm run test:unit:coverage
check_thresholds
}
cmd_merge() {
echo "Merging shard reports from .vitest-reports..."
npx vitest --merge-reports --coverage --run
check_thresholds
}
check_thresholds() {
if [ -f "coverage/coverage-summary.json" ]; then
echo "Checking coverage thresholds..."
python3 <(
@@ -119,6 +129,10 @@ main() {
shift
cmd_test "$@"
;;
merge)
shift
cmd_merge "$@"
;;
html)
shift
cmd_html "$@"
+1
View File
@@ -115,6 +115,7 @@
"eslint-plugin-cypress": "^5.2.0",
"eslint-plugin-vue": "^10.11.1",
"eslint-plugin-vuejs-accessibility": "2.6.0",
"flatted": "^3.4.2",
"fs-extra": "^11.4.1",
"happy-dom": "^21.0.4",
"jsdom": "^27.4.0",
+1
View File
@@ -148,6 +148,7 @@
"eslint-plugin-cypress": "^5.2.0",
"eslint-plugin-vue": "^10.11.1",
"eslint-plugin-vuejs-accessibility": "2.6.0",
"flatted": "^3.4.2",
"fs-extra": "^11.4.1",
"happy-dom": "^21.0.4",
"jsdom": "^27.4.0",
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
// Regenerate ui-shard-durations.json: download the ui-unit-blob-* artifacts of a green CI run (they expire after 1 day), then `node scripts/ui-shard-durations.mjs <dir-with-blob-json-files>`.
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { parse } from 'flatted'
const dir = process.argv[2] ?? '.vitest-reports'
const out = {}
for (const f of readdirSync(dir, { recursive: true }).filter((n) => n.endsWith('.json'))) {
for (const t of parse(readFileSync(join(dir, f), 'utf8'))[1]) {
const ms = (t.result?.duration ?? 0) + (t.collectDuration ?? 0) + (t.setupDuration ?? 0) + (t.environmentLoad ?? 0) + (t.prepareDuration ?? 0)
out[t.filepath.slice(t.filepath.indexOf('/web/') + 5)] = Math.round(ms / 100) / 10
}
}
const sorted = Object.fromEntries(Object.entries(out).sort(([a], [b]) => (a < b ? -1 : 1)))
writeFileSync(new URL('./ui-shard-durations.json', import.meta.url), JSON.stringify(sorted, null, 0).replace(/,"/g, ',\n"') + '\n')
console.log(Object.keys(sorted).length, 'files')
+34
View File
@@ -0,0 +1,34 @@
// Balances --shard=i/N by recorded per-file seconds (greedy longest-first); refresh with scripts/ui-shard-durations.mjs.
import { readFileSync } from 'node:fs'
import { relative } from 'node:path'
import { BaseSequencer } from 'vitest/node'
import type { TestSpecification } from 'vitest/node'
const durations: Record<string, number> = JSON.parse(
readFileSync(new URL('./ui-shard-durations.json', import.meta.url), 'utf8'),
)
export function partition(paths: string[], count: number): string[][] {
const known = Object.values(durations).sort((a, b) => a - b)
const median = known[Math.floor(known.length / 2)] ?? 1
const secs = (p: string) => durations[p] ?? median
const bins: string[][] = Array.from({ length: count }, () => [])
const load = new Array(count).fill(0)
for (const p of [...paths].sort((a, b) => secs(b) - secs(a) || (a < b ? -1 : 1))) {
const i = load.indexOf(Math.min(...load))
bins[i].push(p)
load[i] += secs(p)
}
return bins
}
export default class UiShardSequencer extends BaseSequencer {
async shard(files: TestSpecification[]) {
const { root, shard } = this.ctx.config
if (!shard) return files
const rel = (f: TestSpecification) => relative(root, f.moduleId)
const bins = partition(files.map(rel), shard.count)
const mine = new Set(bins[shard.index - 1])
return files.filter((f) => mine.has(rel(f)))
}
}
@@ -371,6 +371,62 @@ describe("PanelContainer", () => {
});
describe("Panel Controls", () => {
// OTable is covered by PanelDataView.spec; this file's ResizeObserver mock cannot construct.
const dataViewStub = {
PanelDataView: { template: '<div data-test="dashboard-panel-data-view"></div>' },
ODropdown: { template: "<div><slot /></div>" },
ODropdownItem: {
name: "ODropdownItem",
emits: ["select"],
template: '<div v-bind="$attrs" @click="$emit(\'select\')"><slot /></div>',
},
};
const TOGGLE = '[data-test="dashboard-panel-data-view-toggle"]';
const DATA_VIEW = '[data-test="dashboard-panel-data-view"]';
it("switches a chart panel to its raw rows and back from the panel menu", async () => {
wrapper = createWrapper({}, dataViewStub);
expect(wrapper.find(TOGGLE).text()).toBe("Show raw data");
expect(wrapper.find(DATA_VIEW).exists()).toBe(false);
await wrapper.find(TOGGLE).trigger("click");
expect(wrapper.find(DATA_VIEW).exists()).toBe(true);
expect(wrapper.find('[data-test="panel-schema-renderer"]').exists()).toBe(true);
expect(wrapper.find(TOGGLE).text()).toBe("Show chart");
await wrapper.find(TOGGLE).trigger("click");
expect(wrapper.find(DATA_VIEW).exists()).toBe(false);
});
it("hides the legends button while the rows are shown", async () => {
wrapper = createWrapper({ showLegendsButton: true }, dataViewStub);
await wrapper.find('[data-test="dashboard-panel-container"]').trigger("mouseover");
expect(wrapper.find('[data-test="dashboard-show-legends-btn"]').exists()).toBe(true);
await wrapper.find(TOGGLE).trigger("click");
expect(wrapper.find('[data-test="dashboard-show-legends-btn"]').exists()).toBe(false);
});
it("returns to the chart when layout editing starts", async () => {
wrapper = createWrapper({ layoutMode: "view" }, dataViewStub);
await wrapper.find(TOGGLE).trigger("click");
expect(wrapper.find(DATA_VIEW).exists()).toBe(true);
await wrapper.setProps({ layoutMode: "edit" });
expect(wrapper.find(DATA_VIEW).exists()).toBe(false);
});
it.each([
["table panels", { data: { ...defaultProps.data, type: "table" } }],
["kiosk mode", { kiosk: true }],
["view-only panels", { viewOnly: true }],
["simplified panels", { simplifiedPanelView: true }],
])("offers no Data view on %s", (_name, props) => {
wrapper = createWrapper(props, dataViewStub);
expect(wrapper.find(TOGGLE).exists()).toBe(false);
});
it("should show fullscreen button on hover when not view-only", async () => {
wrapper = createWrapper({ viewOnly: false });
@@ -138,6 +138,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
:class="hoverRevealClass"
v-if="
props.showLegendsButton &&
!isDataView &&
!PanleSchemaRendererRef?.noData &&
![
'table',
@@ -177,6 +178,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<OButton
:class="hoverRevealClass"
v-if="
!isDataView &&
!viewOnly &&
!simplifiedPanelView &&
[
@@ -211,7 +213,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OButton>
<ExemplarToggle
v-if="exemplarsEligible && !viewOnly"
v-if="exemplarsEligible && !viewOnly && !isDataView"
:class="exemplarToggleAtRest && !isLayoutEditing ? '' : hoverRevealClass"
:on="exemplarsOn"
:loading="PanleSchemaRendererRef?.exemplarsStatus === 'loading'"
@@ -413,6 +415,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</OTooltip>
</template>
</ODropdownItem>
<ODropdownItem
v-if="showDataViewToggle && !isLayoutEditing"
data-test="dashboard-panel-data-view-toggle"
@select="previewView = isDataView ? 'chart' : 'data'"
>
<template #icon-left
><OIcon :name="isDataView ? 'show-chart' : 'table-chart'" size="sm"
/></template>
{{ isDataView ? t("panel.showChart") : t("panel.showRawData") }}
</ODropdownItem>
<ODropdownItem
v-if="!simplifiedPanelView && layoutMode !== 'view'"
data-test="dashboard-edit-layout"
@@ -573,6 +585,14 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@series-data-update="onCuratedSeriesData"
@send-to-ai-chat="(value, append) => $emit('sendToAiChat', value, append)"
></PanelSchemaRenderer>
<PanelDataView
v-if="showDataOverlay"
class="absolute inset-0 z-10"
:data="previewRawData"
:query-type="props.data.queryType"
:loading="isPanelLoading"
data-test-prefix="dashboard-panel"
/>
<!-- Until the first result or error lands, an empty body would read as "No Data" on a TV. -->
<div
@@ -680,6 +700,8 @@ import { KIOSK_PANEL_HEALTH_KEY } from "@/composables/dashboard/useKioskRefresh"
import { summarizePanelError } from "@/utils/dashboard/kiosk";
import { panelDownloadRegistry, panelCsvRegistry } from "@/utils/panelDownloadRegistry";
import PanelSchemaRenderer from "./PanelSchemaRenderer.vue";
import PanelDataView from "./addPanel/PanelDataView.vue";
import { usePanelDataView } from "@/composables/dashboard/usePanelDataView";
import { useStore } from "vuex";
import { useRoute, useRouter } from "vue-router";
import { addPanel } from "@/utils/commons";
@@ -791,6 +813,7 @@ export default defineComponent({
OBadge,
OSkeleton,
PanelSchemaRenderer,
PanelDataView,
PanelBar,
QueryInspector,
ConfirmDialog,
@@ -890,6 +913,17 @@ export default defineComponent({
);
// need PanleSchemaRendererRef for table download as a csv
const PanleSchemaRendererRef: any = ref(null);
const {
previewView,
showDataViewToggle,
isDataView,
showDataOverlay,
rawData: previewRawData,
} = usePanelDataView({
panelType: () => props.data?.type,
renderer: PanleSchemaRendererRef,
enabled: () => !props.kiosk && !props.viewOnly && !props.simplifiedPanelView,
});
const exemplarsEligible = computed(() => isExemplarEligible(props.data));
const exemplarKey = computed(() =>
@@ -1049,6 +1083,10 @@ export default defineComponent({
// for full screen button
const isCurrentlyHoveredPanel: any = ref(false);
const isLayoutEditing = computed(() => props.layoutMode === "edit");
// Layout editing hides the panel bar controls, which would leave the Data view with no way back.
watch(isLayoutEditing, (editing) => {
if (editing) previewView.value = "chart";
});
// Editor actions follow the "Edit panel" menu item; Refresh is a viewer action, so layout edit and viewOnly keep it.
const canEditFromNotice = computed(
@@ -1585,6 +1623,11 @@ export default defineComponent({
isLayoutEditing,
hoverRevealClass,
doorRevealClass,
previewView,
showDataViewToggle,
isDataView,
showDataOverlay,
previewRawData,
hasNoticeChips,
showViewPanel,
dependentAdHocVariable,
@@ -1087,6 +1087,47 @@ describe("PanelEditor.vue", () => {
});
});
describe("Preview Chart | Data view", () => {
const TOGGLE = { name: "PanelViewToggle" };
beforeEach(() => {
mockChartData.value = {};
mockDashboardPanelData.data.type = "line";
});
afterEach(() => {
mockChartData.value = undefined;
});
const mountEditor = (pageType = "dashboard") =>
shallowMount(PanelEditor, { props: { pageType: pageType as any }, global: mountGlobal });
it("offers the switch on a dashboard chart and starts on Chart", () => {
wrapper = mountEditor();
expect(wrapper.findComponent(TOGGLE).exists()).toBe(true);
expect(wrapper.findComponent({ name: "PanelDataView" }).exists()).toBe(false);
});
it("hides the switch for table panels and outside dashboards", () => {
mockDashboardPanelData.data.type = "table";
wrapper = mountEditor();
expect(wrapper.findComponent(TOGGLE).exists()).toBe(false);
wrapper.unmount();
mockDashboardPanelData.data.type = "line";
wrapper = mountEditor("metrics");
expect(wrapper.findComponent(TOGGLE).exists()).toBe(false);
});
it("overlays the raw rows when Data is picked", async () => {
wrapper = mountEditor();
wrapper.findComponent({ name: "PanelViewToggle" }).vm.$emit("update:modelValue", "data");
await nextTick();
expect(wrapper.findComponent({ name: "PanelDataView" }).exists()).toBe(true);
expect(wrapper.findComponent({ name: "PanelSchemaRenderer" }).exists()).toBe(true);
});
});
describe("renderer errors", () => {
beforeEach(() => {
mockChartData.value = {};
@@ -197,8 +197,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Warning icons and last refreshed time -->
<div class="me-2 flex items-center justify-end gap-2">
<PanelViewToggle
v-if="showDataViewToggle"
v-model="previewView"
class="my-2 ms-2 me-auto"
data-test-prefix="panel-editor"
/>
<OButton
v-if="
!isDataView &&
![
'table',
'heatmap',
@@ -233,6 +241,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<!-- Add Annotations button -->
<OButton
v-if="
!isDataView &&
editMode &&
pageType === 'dashboard' &&
[
@@ -342,6 +351,15 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
@format-column="openColumnFormatting"
/>
</div>
<!-- Overlays the chart instead of replacing it so the query result and chart state survive the switch. -->
<PanelDataView
v-if="showDataOverlay && chartData"
class="absolute inset-0 z-10"
:data="previewRawData"
:query-type="dashboardPanelData.data.queryType"
:loading="isPanelLoading"
data-test-prefix="panel-editor"
/>
</div>
<!-- Errors Component -->
@@ -808,6 +826,9 @@ import DashboardQueryBuilder from "@/components/dashboards/addPanel/DashboardQue
import DashboardErrorsComponent from "@/components/dashboards/addPanel/DashboardErrors.vue";
import PanelSchemaRenderer from "@/components/dashboards/PanelSchemaRenderer.vue";
import PanelErrorButtons from "@/components/dashboards/PanelErrorButtons.vue";
import PanelDataView from "@/components/dashboards/addPanel/PanelDataView.vue";
import PanelViewToggle from "@/components/dashboards/PanelViewToggle.vue";
import { usePanelDataView } from "@/composables/dashboard/usePanelDataView";
import { usePanelEditorFocus } from "@/composables/dashboard/usePanelEditorFocus";
import type { PanelNoticeActionId } from "@/utils/dashboard/panelNotices";
import OButton from "@/lib/core/Button/OButton.vue";
@@ -1017,6 +1038,18 @@ const seriesOverridePreviewSource = computed(() => {
};
});
const {
previewView,
showDataViewToggle,
isDataView,
showDataOverlay,
rawData: previewRawData,
} = usePanelDataView({
panelType: () => dashboardPanelData.data.type,
renderer: panelSchemaRendererRef,
enabled: () => props.pageType === "dashboard",
});
// The desktop 20% field-list splitter is unusable on a phone, so there it opens at ~half width.
const { isMobile } = useBreakpoint();
const mobileFieldsOpen = ref(false);
@@ -2636,6 +2636,7 @@ export default defineComponent({
chartPanelRef,
chartRendererRef,
data,
filteredData,
loading,
searchRequestTraceIds,
errorDetail,
@@ -0,0 +1,55 @@
<!-- 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/>.
-->
<template>
<OToggleGroup
:model-value="modelValue"
:aria-label="t('panel.previewView')"
:data-test="`${dataTestPrefix}-preview-view-toggle`"
@update:model-value="(v) => emit('update:modelValue', v as PanelPreviewView)"
>
<OToggleGroupItem
value="chart"
size="xs"
icon-left="show-chart"
:data-test="`${dataTestPrefix}-preview-view-chart`"
>{{ t("panel.previewChart") }}</OToggleGroupItem
>
<OToggleGroupItem
value="data"
size="xs"
icon-left="table-chart"
:data-test="`${dataTestPrefix}-preview-view-data`"
>{{ t("panel.previewData") }}</OToggleGroupItem
>
</OToggleGroup>
</template>
<script setup lang="ts">
import OToggleGroup from "@/lib/core/ToggleGroup/OToggleGroup.vue";
import OToggleGroupItem from "@/lib/core/ToggleGroup/OToggleGroupItem.vue";
import type { PanelPreviewView } from "@/composables/dashboard/usePanelDataView";
import { useI18nTyped } from "@/types/i18n";
defineProps<{
modelValue: PanelPreviewView;
dataTestPrefix: string;
}>();
const emit = defineEmits<{ "update:modelValue": [value: PanelPreviewView] }>();
const { t } = useI18nTyped();
</script>
@@ -0,0 +1,101 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import i18n from "@/locales";
import PanelDataView from "./PanelDataView.vue";
const mountView = (props: Record<string, unknown>) =>
mount(PanelDataView, {
props: { dataTestPrefix: "test", ...props },
global: { plugins: [i18n] },
});
describe("PanelDataView", () => {
it("shows the query result columns and rows", () => {
const wrapper = mountView({
data: [
[
{ x_axis_1: "api", "a.b": 3 },
{ x_axis_1: "web", "a.b": 4 },
],
],
});
const table = wrapper.findComponent({ name: "OTable" });
expect(table.props("columns").map((c: any) => c.id)).toEqual(["x_axis_1", "a.b"]);
expect(table.props("data")).toHaveLength(2);
expect(table.props("columns")[1].accessorFn(table.props("data")[0])).toBe(3);
});
it("gives duplicate rows distinct keys", () => {
const row = { x_axis_1: "api", y_axis_1: 1 };
const wrapper = mountView({ data: [[row, { ...row }]] });
const table = wrapper.findComponent({ name: "OTable" });
const rowKey = table.props("rowKey") as (r: any) => string;
const [a, b] = table.props("data");
expect(rowKey(a)).not.toBe(rowKey(b));
});
it("flattens PromQL range and instant series into timestamp, value and labels", () => {
const wrapper = mountView({
queryType: "promql",
data: [
{
result: [
{ metric: { job: "api" }, values: [[1, "5"]] },
{ metric: { job: "web" }, value: [2, "7"] },
],
},
],
});
const table = wrapper.findComponent({ name: "OTable" });
expect(table.props("columns").map((c: any) => c.id)).toEqual(["timestamp", "value", "job"]);
expect(table.props("data")).toHaveLength(2);
});
it("shows its own empty message when the query returned nothing", () => {
const table = mountView({ data: [[]] }).findComponent({ name: "OTable" });
expect(table.props("data")).toHaveLength(0);
expect(table.props("emptyMessage")).toBe("No rows returned");
});
it("keeps the rows on screen while a refresh streams in", () => {
const table = mountView({ data: [[{ a: 1 }]], loading: true }).findComponent({
name: "OTable",
});
expect(table.props("loading")).toBe(false);
expect(table.props("streaming")).toBe(true);
});
it("caps very large results and says so", () => {
const rows = Array.from({ length: 100005 }, (_, i) => ({ i }));
// jsdom has no layout, so a real virtual-scrolled OTable would draw every row.
const wrapper = mount(PanelDataView, {
props: { dataTestPrefix: "test", data: [rows] },
global: {
plugins: [i18n],
stubs: { OTable: { name: "OTable", props: ["data"], template: "<div />" } },
},
});
expect(wrapper.findComponent({ name: "OTable" }).props("data")).toHaveLength(100000);
expect(wrapper.find('[data-test="test-data-view-capped"]').text()).toContain("100,005");
});
it("aligns a numeric column right even when its first value is empty", () => {
const table = mountView({ data: [[{ n: null }, { n: 4 }]] }).findComponent({ name: "OTable" });
expect(table.props("columns")[0].meta.align).toBe("right");
});
});
@@ -0,0 +1,98 @@
<!-- 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/>.
-->
<template>
<div
class="bg-surface-base flex h-full min-h-0 flex-col"
:data-test="`${dataTestPrefix}-data-view`"
>
<OTable
:data="tableRows"
:columns="columns"
:row-key="rowKeyOf"
:loading="loading && !tableRows.length"
:streaming="loading && tableRows.length > 0"
:empty-message="t('panel.dataViewEmpty')"
:show-global-filter="false"
pagination="none"
virtual-scroll
window-row-model
:row-height="22"
horizontal-scroll
:data-test="`${dataTestPrefix}-data-view-table`"
/>
<div
v-if="isCapped"
class="text-text-secondary border-border-default shrink-0 border-t px-2 py-1 text-xs"
:data-test="`${dataTestPrefix}-data-view-capped`"
>
{{
t("panel.dataViewCapped", {
shown: MAX_ROWS.toLocaleString(),
total: result.rows.length.toLocaleString(),
})
}}
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import OTable from "@/lib/core/Table/OTable.vue";
import type { OTableColumnDef } from "@/lib/core/Table/OTable.types";
import { flattenPanelResult } from "@/composables/dashboard/usePanelActions";
import { raw, useI18nTyped } from "@/types/i18n";
const props = defineProps<{
data: any;
queryType?: string;
loading?: boolean;
dataTestPrefix: string;
}>();
// Flattening copies every point of a dense PromQL range result, so very large results are cut.
const MAX_ROWS = 100000;
const { t } = useI18nTyped();
const result = computed(() => flattenPanelResult(props.data, props.queryType));
const isCapped = computed(() => result.value.rows.length > MAX_ROWS);
const tableRows = computed(() =>
isCapped.value ? result.value.rows.slice(0, MAX_ROWS) : result.value.rows,
);
// Query rows carry no unique field, so the position is the identity.
const rowIndex = computed(() => new WeakMap(tableRows.value.map((row, index) => [row, index])));
const rowKeyOf = (row: Record<string, any>) => String(rowIndex.value.get(row));
const isNumericColumn = (key: string) => {
const sample = result.value.rows.find((row) => row[key] != null)?.[key];
return typeof sample === "number";
};
const columns = computed<OTableColumnDef[]>(() =>
result.value.columns.map((key) => ({
id: key,
header: raw(key),
// accessorFn, not accessorKey: aliases may contain dots, which TanStack reads as a path.
accessorFn: (row: Record<string, any>) => row[key],
meta: { align: isNumericColumn(key) ? "right" : "left" },
})),
);
</script>
@@ -102,6 +102,12 @@ describe("LogsPanelRenderer", () => {
expect(table().props("pinnedFirstColumn")).toBe("_timestamp");
});
it("leaves the empty state to the panel so no second No Data message stacks under it", async () => {
await mountPanel({}, []);
expect(table().exists()).toBe(false);
expect(wrapper!.find('[data-test="o2-table-empty"]').exists()).toBe(false);
});
it("colours each row's severity rail from the level field, like the logs table", async () => {
await mountPanel();
const data = table().props("data") as any[];
@@ -16,7 +16,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template>
<div class="flex h-full min-h-0 w-full flex-col" data-test="dashboard-logs-panel">
<!-- Without rows PanelSchemaRenderer draws the No Data state; an empty slot would not stop OTable's own. -->
<OTable
v-if="rows.length > 0"
class="logs-panel-table min-h-0 w-full flex-1"
:columns="columns"
:data="sourceRows"
@@ -1031,4 +1031,37 @@ describe("ViewPanel", () => {
expect(popup.props("open")).toBe(false);
});
});
describe("Chart | Data view", () => {
const dataViewStub = {
PanelDataView: { template: '<div data-test="dashboard-viewpanel-data-view"></div>' },
};
afterEach(() => {
mockDashboardPanelData.data.type = "line";
});
it("overlays the raw rows on the chart when Data is picked", async () => {
wrapper = createWrapper({}, dataViewStub);
wrapper.vm.chartData = mockDashboardPanelData.data;
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="dashboard-viewpanel-data-view"]').exists()).toBe(false);
await wrapper.find('[data-test="dashboard-viewpanel-preview-view-data"]').trigger("click");
expect(wrapper.find('[data-test="dashboard-viewpanel-data-view"]').exists()).toBe(true);
expect(wrapper.find('[data-test="dashboard-viewpanel-panel-schema-renderer"]').exists()).toBe(
true,
);
});
it("offers no Data view for a table panel", async () => {
mockDashboardPanelData.data.type = "table";
wrapper = createWrapper({}, dataViewStub);
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-test="dashboard-viewpanel-preview-view-toggle"]').exists()).toBe(
false,
);
});
});
});
@@ -185,6 +185,12 @@
class="me-2 flex shrink-0 items-center justify-end"
data-test="view-panel-last-refreshed-at"
>
<PanelViewToggle
v-if="showDataViewToggle"
v-model="previewView"
class="my-2 ms-2 me-auto"
data-test-prefix="dashboard-viewpanel"
/>
<!-- Error/Warning tooltips -->
<PanelErrorButtons
:error="errorMessage"
@@ -203,44 +209,54 @@
:viewOnly="false"
/>
</div>
<PanelSchemaRenderer
class="min-h-0 flex-1"
v-if="chartData"
:key="dashboardPanelData.data.type"
:panelSchema="chartData"
:dashboard-id="dashboardId"
:folder-id="folderId"
:selectedTimeObj="dashboardPanelData.meta.dateTime"
:variablesData="currentVariablesDataRef"
:currentVariablesData="liveVariablesData"
:tabId="currentTabId"
:panelId="currentPanelId"
:width="6"
:searchType="searchType"
:showLegendsButton="true"
:exemplars-override="viewExemplarOverride"
@error="handleChartApiError"
@updated:data-zoom="onDataZoom"
@update:initialVariableValues="onUpdateInitialVariableValues"
@last-triggered-at-update="handleLastTriggeredAtUpdate"
@result-metadata-update="handleResultMetadataUpdate"
@panel-diagnostics-update="handlePanelDiagnosticsUpdate"
@series-limit-update="handleSeriesLimitUpdate"
@region-join-update="handleRegionJoinUpdate"
@sankey-notice-update="handleSankeyNoticeUpdate"
@limit-number-of-series-warning-message-update="
handleLimitNumberOfSeriesWarningMessage
"
@sparkline-warning-update="handleSparklineWarningUpdate"
@is-partial-data-update="handleIsPartialDataUpdate"
@loading-state-change="handleLoadingStateChange"
@is-cached-data-differ-with-current-time-range-update="
handleIsCachedDataDifferWithCurrentTimeRangeUpdate
"
@show-legends="showLegendsDialog = true"
data-test="dashboard-viewpanel-panel-schema-renderer"
ref="panelSchemaRendererRef"
/>
<div class="relative flex min-h-0 flex-1 flex-col">
<PanelSchemaRenderer
class="min-h-0 flex-1"
v-if="chartData"
:key="dashboardPanelData.data.type"
:panelSchema="chartData"
:dashboard-id="dashboardId"
:folder-id="folderId"
:selectedTimeObj="dashboardPanelData.meta.dateTime"
:variablesData="currentVariablesDataRef"
:currentVariablesData="liveVariablesData"
:tabId="currentTabId"
:panelId="currentPanelId"
:width="6"
:searchType="searchType"
:showLegendsButton="true"
:exemplars-override="viewExemplarOverride"
@error="handleChartApiError"
@updated:data-zoom="onDataZoom"
@update:initialVariableValues="onUpdateInitialVariableValues"
@last-triggered-at-update="handleLastTriggeredAtUpdate"
@result-metadata-update="handleResultMetadataUpdate"
@panel-diagnostics-update="handlePanelDiagnosticsUpdate"
@series-limit-update="handleSeriesLimitUpdate"
@region-join-update="handleRegionJoinUpdate"
@sankey-notice-update="handleSankeyNoticeUpdate"
@limit-number-of-series-warning-message-update="
handleLimitNumberOfSeriesWarningMessage
"
@sparkline-warning-update="handleSparklineWarningUpdate"
@is-partial-data-update="handleIsPartialDataUpdate"
@loading-state-change="handleLoadingStateChange"
@is-cached-data-differ-with-current-time-range-update="
handleIsCachedDataDifferWithCurrentTimeRangeUpdate
"
@show-legends="showLegendsDialog = true"
data-test="dashboard-viewpanel-panel-schema-renderer"
ref="panelSchemaRendererRef"
/>
<PanelDataView
v-if="showDataOverlay && chartData"
class="absolute inset-0 z-10"
:data="previewRawData"
:query-type="dashboardPanelData.data.queryType"
:loading="isPanelLoading"
data-test-prefix="dashboard-viewpanel"
/>
</div>
</div>
<DashboardErrorsComponent
:errors="errorData"
@@ -283,6 +299,9 @@ import DateTimePickerDashboard from "../../../components/DateTimePickerDashboard
import DashboardErrorsComponent from "../../../components/dashboards/addPanel/DashboardErrors.vue";
import VariablesValueSelector from "../../../components/dashboards/VariablesValueSelector.vue";
import PanelSchemaRenderer from "../../../components/dashboards/PanelSchemaRenderer.vue";
import PanelDataView from "@/components/dashboards/addPanel/PanelDataView.vue";
import PanelViewToggle from "@/components/dashboards/PanelViewToggle.vue";
import { usePanelDataView } from "@/composables/dashboard/usePanelDataView";
// import _ from "lodash-es";
import AutoRefreshInterval from "@/components/AutoRefreshInterval.vue";
import { parseDuration } from "@/utils/date";
@@ -338,6 +357,8 @@ export default defineComponent({
DashboardErrorsComponent,
VariablesValueSelector,
PanelSchemaRenderer,
PanelDataView,
PanelViewToggle,
AutoRefreshInterval,
HistogramIntervalDropDown,
ShowLegendsPopup,
@@ -408,6 +429,15 @@ export default defineComponent({
dashboardPanelDataPageKey,
t,
);
const {
previewView,
showDataViewToggle,
showDataOverlay,
rawData: previewRawData,
} = usePanelDataView({
panelType: () => dashboardPanelData.data.type,
renderer: panelSchemaRendererRef,
});
// The applied range the chart loads with; the picker's own value is `timeGate.picked`.
const selectedDate: any = ref(props.selectedDateForViewPanel);
const dateTimePickerRef: any = ref(null);
@@ -1073,6 +1103,10 @@ export default defineComponent({
showLegendsDialog,
currentPanelData,
panelSchemaRendererRef,
previewView,
showDataViewToggle,
showDataOverlay,
previewRawData,
isPartialData,
isPanelLoading,
isCachedDataDifferWithCurrentTimeRange,
@@ -304,11 +304,12 @@ describe("AssociatedStreamFunction", () => {
});
describe("Stream Data Mapping", () => {
it("should filter out enrichment_tables streams", async () => {
it("should filter out enrichment_tables and profiles streams", async () => {
mockGetStreams.mockResolvedValue({
list: [
{ name: "stream1", stream_type: "logs", stats: null },
{ name: "enrich_table", stream_type: "enrichment_tables", stats: null },
{ name: "prof1", stream_type: "profiles", stats: null },
],
});
@@ -319,8 +320,7 @@ describe("AssociatedStreamFunction", () => {
await flushPromises();
const vm = wrapper.vm as any;
expect(vm.logStream.every((s: any) => s.stream_type !== "enrichment_tables")).toBe(true);
expect(vm.logStream).toHaveLength(1);
expect(vm.logStream.map((s: any) => s.name)).toEqual(["stream1"]);
});
it("should map stream stats correctly when stats exist", async () => {
@@ -377,7 +377,10 @@ export default defineComponent({
let compressed_size = "";
resultTotal.value = res.list.length;
logStream.value = res.list
.filter((stream: any) => stream.stream_type !== "enrichment_tables")
// profiles ingestion runs no functions
.filter(
(stream: any) => !["enrichment_tables", "profiles"].includes(stream.stream_type),
)
.map((data: any) => {
doc_num = "--";
storage_size = "--";
@@ -178,7 +178,9 @@ const mountPanel = async (
return wrapper;
};
const q = (id: string) => wrapper.find(`[data-test="${id}"]`);
const seedLists = (lists: Partial<Record<"logs" | "metrics" | "traces", unknown[]>>) => {
const seedLists = (
lists: Partial<Record<"logs" | "metrics" | "traces" | "profiles", unknown[]>>,
) => {
for (const [type, list] of Object.entries(lists)) {
queryClient.setQueryData(streamKeys.nameList(ORG, type), list);
}
@@ -225,7 +227,7 @@ describe("FirstDataPanel — flag off", () => {
it("draws no compact card", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel({ signal: "any", variant: "compact" }, { flag: false, at: "home" });
expect(wrapper.html()).not.toContain("first-data-panel");
expect(watchArgs).not.toHaveBeenCalled();
@@ -372,7 +374,12 @@ describe("FirstDataPanel — no pick or a pick of another signal", () => {
describe("FirstDataPanel — compact card on Dashboards and Home", () => {
it("shows the pick, the pill, Copy command and Open the guide on an empty org", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [{ name: "usage", stream_type: "logs" }], metrics: [], traces: [] });
seedLists({
logs: [{ name: "usage", stream_type: "logs" }],
metrics: [],
traces: [],
profiles: [],
});
await mountPanel({ signal: "any", variant: "compact" }, { at: "dashboards" });
const panel = q("first-data-panel");
@@ -395,7 +402,7 @@ describe("FirstDataPanel — compact card on Dashboards and Home", () => {
it("titles the card with the org name, not its identifier", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(
{ signal: "any", variant: "compact" },
{ at: "dashboards", orgName: "Acme Production" },
@@ -406,7 +413,7 @@ describe("FirstDataPanel — compact card on Dashboards and Home", () => {
it("names the org from the org list when the selection has no label", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel({ signal: "any", variant: "compact" }, { at: "dashboards" });
store.state.organizations = [{ identifier: ORG, name: "Acme Production" }];
await flushPromises();
@@ -415,7 +422,7 @@ describe("FirstDataPanel — compact card on Dashboards and Home", () => {
it("speaks for the Home tab it stands in for, the command line in its own span", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel({ signal: "any", variant: "compact", context: "overview" }, { at: "home" });
const spans = q("first-data-panel-compact-text").findAll("span");
expect(spans.map((sp) => sp.text())).toEqual([
@@ -434,14 +441,19 @@ describe("FirstDataPanel — compact card on Dashboards and Home", () => {
expect(q("first-data-panel").exists()).toBe(false);
expect(watchArgs).not.toHaveBeenCalled();
seedLists({ logs: [{ name: "app", stream_type: "logs" }], metrics: [], traces: [] });
seedLists({
logs: [{ name: "app", stream_type: "logs" }],
metrics: [],
traces: [],
profiles: [],
});
await flushPromises();
expect(q("first-data-panel").exists()).toBe(false);
});
it("stays hidden once the org has data", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel({ signal: "any", variant: "compact" }, { at: "home", ingested: true });
expect(q("first-data-panel").exists()).toBe(false);
});
@@ -527,14 +539,14 @@ describe("FirstDataPanel — Home layouts", () => {
expect(layout()).toBe("pending");
expect(watchArgs).not.toHaveBeenCalled();
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await flushPromises();
expect(layout()).toBe("status");
});
it("keeps the card and its watcher while the watcher's list refresh shows the stream it is confirming", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(homeProps, { at: "home", slot: layoutOf });
expect(layout()).toBe("card");
@@ -548,7 +560,7 @@ describe("FirstDataPanel — Home layouts", () => {
vi.useFakeTimers();
try {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(homeProps, { at: "home", slot: layoutOf });
store.state.organizationData.isDataIngested = true;
await flushPromises();
@@ -575,7 +587,7 @@ describe("FirstDataPanel — Home layouts", () => {
vi.useFakeTimers();
try {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(homeProps, { at: "home", slot: layoutOf });
store.state.organizationData.isDataIngested = true;
await flushPromises();
@@ -590,7 +602,12 @@ describe("FirstDataPanel — Home layouts", () => {
it("lets an org with data mount its own view once the lists arrive", async () => {
await mountPanel(homeProps, { at: "home", slot: layoutOf });
seedLists({ logs: [{ name: "app", stream_type: "logs" }], metrics: [], traces: [] });
seedLists({
logs: [{ name: "app", stream_type: "logs" }],
metrics: [],
traces: [],
profiles: [],
});
await flushPromises();
expect(layout()).toBe("none");
});
@@ -602,7 +619,7 @@ describe("FirstDataPanel — Home layouts", () => {
"keeps the empty state and the pill when a list failed for another reason, with %s",
async (_label, pickId, expected) => {
if (pickId) window.localStorage.setItem(PICK_KEY, pickId);
for (const type of ["logs", "metrics", "traces"]) await failList(type, 500);
for (const type of ["logs", "metrics", "traces", "profiles"]) await failList(type, 500);
await mountPanel(homeProps, { at: "home", slot: layoutOf });
expect(layout()).toBe(expected);
expect(q("first-event-status").exists()).toBe(true);
@@ -618,7 +635,7 @@ describe("FirstDataPanel — Home layouts", () => {
it("hands a no-pick status line laid out for a hero", async () => {
window.localStorage.setItem(PICK_KEY, "unsure");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(homeProps, { at: "home", slot: layoutOf });
expect(layout()).toBe("status");
expect(q("first-data-panel").classes()).toContain("justify-center");
@@ -626,7 +643,7 @@ describe("FirstDataPanel — Home layouts", () => {
it("reports card when it draws the compact card above the page", async () => {
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(homeProps, { at: "home", slot: layoutOf });
expect(layout()).toBe("card");
expect(q("first-data-panel").attributes("data-variant")).toBe("compact");
@@ -662,7 +679,7 @@ describe("FirstDataPanel — the org credential, loaded where the snippet is", (
it("loads the credential itself on Home and copies the org token, not an empty password", async () => {
credentialApi.list_org_ingestion_tokens.mockResolvedValue({ data: { data: [tokenRow] } });
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel(
{ signal: "any", variant: "compact", context: "overview" },
{ at: "home", passcode: "" },
@@ -687,7 +704,7 @@ describe("FirstDataPanel — the org credential, loaded where the snippet is", (
it("offers no command while the credential is unknown, so nothing empty is copied", async () => {
credentialApi.get_organization_passcode.mockReturnValue(new Promise(() => {}));
window.localStorage.setItem(PICK_KEY, "kubernetes");
seedLists({ logs: [], metrics: [], traces: [] });
seedLists({ logs: [], metrics: [], traces: [], profiles: [] });
await mountPanel({ signal: "any", variant: "compact" }, { at: "dashboards", passcode: "" });
expect(q("first-data-panel").exists()).toBe(true);
expect(q("first-data-panel-copy-btn").exists()).toBe(false);
@@ -118,7 +118,7 @@ describe("McpServerCard", () => {
const wrapper = mountCard();
expect(codeBlock(wrapper, "ai-integrations-mcp-skills-cmd").props("code")).toBe(
"npx skills add openobserve/skills --skill openobserve",
"npx @openobserve/skills add --all",
);
});
@@ -96,8 +96,9 @@ const YOUR_TOKEN = "<your-token>";
const MASKED_AUTH = "Basic ••••••••••••••••";
const SKILLS_REPO_URL = "https://www.npmjs.com/package/@openobserve/skills";
// No -a flag: the CLI then asks which agents to install to, so one command serves every client.
const SKILLS_INSTALL_CMD = "npx skills add openobserve/skills --skill openobserve";
// The skills ship as the npm package @openobserve/skills; github.com/openobserve/skills does not
// exist. --all installs every skill into each AI tool the CLI finds on the machine.
const SKILLS_INSTALL_CMD = "npx @openobserve/skills add --all";
// The Authorization header VALUE injected into token-mode snippets. OAuth mode
// passes null so build() omits the header entirely.
+18 -17
View File
@@ -638,25 +638,26 @@ describe("Schema Component Tests", () => {
it("should compute store original data toggle visibility", async () => {
expect(wrapper.vm.showStoreOriginalDataToggle).toBe(true);
// Test with traces
const tracesWrapper = mount(LogStream, {
props: {
modelValue: {
name: "test-stream",
stream_type: "traces",
settings: { defined_schema_fields: [] },
for (const streamType of ["traces", "metrics", "profiles", "metadata", "enrichment_tables"]) {
const otherWrapper = mount(LogStream, {
props: {
modelValue: {
name: "test-stream",
stream_type: streamType,
settings: { defined_schema_fields: [] },
},
},
},
global: {
provide: { store: store },
plugins: [i18n],
stubs: { ODrawer: ODrawerStub },
},
});
global: {
provide: { store: store },
plugins: [i18n],
stubs: { ODrawer: ODrawerStub },
},
});
await flushPromises();
expect(tracesWrapper.vm.showStoreOriginalDataToggle).toBe(false);
tracesWrapper.unmount();
await flushPromises();
expect(otherWrapper.vm.showStoreOriginalDataToggle).toBe(false);
otherWrapper.unmount();
}
});
// Test 41-50: Additional edge cases and error scenarios
+2 -1
View File
@@ -1219,8 +1219,9 @@ export default defineComponent({
approxPartition.value = false;
});
// only the logs ingest path writes the original record
const showStoreOriginalDataToggle = computed(() => {
return modelValue.stream_type !== "traces";
return modelValue.stream_type === "logs";
});
//here we added a watcher to
//1. if user defined schema is enabled then we need to show the schema fields tab and also need to make sure that it would be the active tab
@@ -16,7 +16,12 @@
import { afterEach, describe, expect, it, beforeEach, vi } from "vitest";
import { setLanguage } from "@/utils/cookies";
import { gt } from "@/types/i18n";
import { wrapCsvValue, usePanelAlertCreation, usePanelDownload } from "./usePanelActions";
import {
flattenPanelResult,
wrapCsvValue,
usePanelAlertCreation,
usePanelDownload,
} from "./usePanelActions";
import { downloadFile } from "@/utils/dom";
import { readAlertPrefill } from "@/utils/alerts/alertPrefillStorage";
import { buildForecastAlertPromql, parseForecastAlertPromql } from "@/utils/alerts/forecastAlert";
@@ -69,6 +74,51 @@ describe("usePanelActions", () => {
});
});
describe("flattenPanelResult", () => {
it("joins every SQL dataset and keeps columns in first-seen order", () => {
const out = flattenPanelResult([
[{ x_axis_1: "a", y_axis_1: 1 }],
[{ x_axis_1: "b", y_axis_1: 2, breakdown_1: "z" }],
]);
expect(out.columns).toEqual(["x_axis_1", "y_axis_1", "breakdown_1"]);
expect(out.rows).toHaveLength(2);
});
it("turns PromQL series into one row per point with its labels", () => {
const out = flattenPanelResult(
[
{
result: [
{
metric: { job: "api" },
values: [
[1, "5"],
[2, "6"],
],
},
],
},
],
"promql",
);
expect(out.columns).toEqual(["timestamp", "value", "job"]);
expect(out.rows[1]).toEqual({ timestamp: 2, value: "6", job: "api" });
});
it("reads the single value pair of a PromQL instant query", () => {
const out = flattenPanelResult(
[{ result: [{ metric: { job: "api" }, value: [3, "9"] }] }],
"promql",
);
expect(out.rows).toEqual([{ timestamp: 3, value: "9", job: "api" }]);
});
it("returns nothing for a missing or malformed response", () => {
expect(flattenPanelResult(undefined)).toEqual({ columns: [], rows: [] });
expect(flattenPanelResult([null, {}], "promql")).toEqual({ columns: [], rows: [] });
});
});
describe("usePanelAlertCreation", () => {
const makeBase = () => {
const panelSchema = {
@@ -975,6 +1025,29 @@ describe("usePanelActions", () => {
);
});
it("exports a PromQL instant result as CSV", () => {
const deps = makeDeps();
deps.panelSchema.value.queryType = "promql";
(downloadFile as any).mockReturnValue(true);
usePanelDownload(deps as any).downloadDataAsCSV("instant");
expect(downloadFile).toHaveBeenCalledWith(
"instant.csv",
"timestamp,value\r\n1,10",
"text/csv",
);
});
it("reports no data instead of an empty PromQL CSV", () => {
const deps = makeDeps();
deps.panelSchema.value.queryType = "promql";
deps.filteredData.value = [{ result: [] }];
usePanelDownload(deps as any).downloadDataAsCSV("empty");
expect(downloadFile).not.toHaveBeenCalled();
expect(deps.showErrorNotification).toHaveBeenCalledWith("No data available to download");
});
it("exports PromQL filtered data as JSON", () => {
const deps = makeDeps();
deps.panelSchema.value.queryType = "promql";
@@ -73,6 +73,34 @@ export const forecastPointOf = (series: any, dataIndex: number | undefined) => {
};
};
/** Flattens a panel query response into rows plus their columns in first-seen order. */
export const flattenPanelResult = (
data: any,
queryType?: string,
): { columns: string[]; rows: Record<string, any>[] } => {
const rows: Record<string, any>[] = [];
if (queryType === "promql") {
(data ?? []).forEach((promData: any) => {
if (!Array.isArray(promData?.result)) return;
promData.result.forEach((series: any) => {
// Instant queries return one `value` pair instead of a `values` range.
const points = series.values ?? (series.value ? [series.value] : []);
points.forEach((point: any) => {
rows.push({ timestamp: point[0], value: point[1], ...(series.metric ?? {}) });
});
});
});
} else {
(data ?? []).forEach((dataset: any) => {
if (!Array.isArray(dataset)) return;
rows.push(...dataset);
});
}
const columns = new Set<string>();
rows.forEach((row) => Object.keys(row).forEach((key) => columns.add(key)));
return { columns: Array.from(columns), rows };
};
/** The SQL column a right-clicked threshold applies to, read off the query's y-axis or its SQL. */
const sqlYAxisColumnOf = (
query: any,
@@ -367,102 +395,33 @@ export function usePanelDownload({
return;
}
let csvContent;
const isPromql = panelSchema.value.queryType === "promql";
// PromQL reads filteredData so hidden queries stay out of the export.
const { columns, rows } = flattenPanelResult(
isPromql ? filteredData?.value : data?.value,
panelSchema.value.queryType,
);
// Check if this is a PromQL query type panel
if (panelSchema.value.queryType === "promql") {
// Handle PromQL data format
const flattenedData: any[] = [];
// Iterate through each response item (multiple queries can produce multiple responses)
// Use filteredData to exclude hidden queries
filteredData?.value?.forEach((promData: any) => {
if (!promData?.result || !Array.isArray(promData.result)) return;
// Iterate through each result (time series)
promData.result.forEach((series: any) => {
const metricLabels = series.metric || {};
// Iterate through values array (timestamp, value pairs)
series.values.forEach((point: any) => {
const timestamp = point[0];
const value = point[1];
// Create a row with timestamp, value, and all metric labels
const row = {
timestamp: timestamp,
value: value,
...metricLabels,
};
flattenedData.push(row);
});
});
});
// Get all unique keys across all data points
const allKeys = new Set();
flattenedData.forEach((row: any) => {
Object.keys(row).forEach((key: any) => allKeys.add(key));
});
// Convert Set to Array and ensure timestamp and value come first
const keys = Array.from(allKeys);
keys.sort((a: any, b: any) => {
if (a === "timestamp") return -1;
if (b === "timestamp") return 1;
if (a === "value") return -1;
if (b === "value") return 1;
return a.localeCompare(b);
});
// Create CSV content
csvContent = [
keys.join(","), // Headers row
...flattenedData.map((row: any) =>
keys.map((key: any) => wrapCsvValue(row[key] ?? "")).join(","),
),
].join("\r\n");
} else {
// Handle standard SQL format - now supporting multiple arrays
const flattenedData: any[] = [];
// Iterate through all datasets/arrays in the response
data?.value?.forEach((dataset: any) => {
// Skip if dataset is empty or not an array
if (!dataset || !Array.isArray(dataset) || dataset.length === 0) return;
dataset.forEach((row: any) => {
flattenedData.push({
...row,
});
});
});
// If after flattening we have no data, show notification and return
if (flattenedData.length === 0) {
showErrorNotification(t("dashboard.noDataAvailableToDownload"));
return;
}
// Collect all possible keys from all objects
const allKeys = new Set();
flattenedData.forEach((row: any) => {
Object.keys(row).forEach((key: any) => allKeys.add(key));
});
// Convert Set to Array and sort for consistent order
const headers = Array.from(allKeys).sort();
// Create CSV content with headers and data rows
csvContent = [
headers.join(","), // Headers row
...flattenedData.map((row: any) =>
headers.map((header: any) => wrapCsvValue(row[header] ?? "")).join(","),
),
].join("\r\n");
if (rows.length === 0) {
showErrorNotification(t("dashboard.noDataAvailableToDownload"));
return;
}
const headers = isPromql
? columns.sort((a, b) => {
if (a === "timestamp") return -1;
if (b === "timestamp") return 1;
if (a === "value") return -1;
if (b === "value") return 1;
return a.localeCompare(b);
})
: columns.sort();
const csvContent = [
headers.join(","),
...rows.map((row) => headers.map((key) => wrapCsvValue(row[key] ?? "")).join(",")),
].join("\r\n");
const status = downloadFile((title ?? "chart-export") + ".csv", csvContent, "text/csv");
if (status === true) {
@@ -0,0 +1,65 @@
// Copyright 2026 OpenObserve Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
import { describe, expect, it } from "vitest";
import { ref } from "vue";
import { usePanelDataView } from "./usePanelDataView";
describe("usePanelDataView", () => {
const renderer = ref<any>({ data: [["all"]], filteredData: [["visible"]] });
it("starts on Chart and switches to Data", () => {
const view = usePanelDataView({ panelType: "line", renderer });
expect(view.showDataViewToggle.value).toBe(true);
expect(view.isDataView.value).toBe(false);
view.previewView.value = "data";
expect(view.isDataView.value).toBe(true);
});
it.each(["table", "logs", "html", "markdown", "alert-list", "custom_chart"])(
"offers no Data view for %s",
(type) => {
const view = usePanelDataView({ panelType: type, renderer });
view.previewView.value = "data";
expect(view.showDataViewToggle.value).toBe(false);
expect(view.isDataView.value).toBe(false);
},
);
it("respects the enabled flag", () => {
const view = usePanelDataView({
panelType: "line",
queryType: "sql",
renderer,
enabled: false,
});
expect(view.showDataViewToggle.value).toBe(false);
});
it("reads the legend-filtered result so hidden queries stay out", () => {
const view = usePanelDataView({ panelType: "line", renderer });
expect(view.rawData.value).toEqual([["visible"]]);
});
it("keeps the error on screen instead of overlaying rows", () => {
const failing = ref<any>({ filteredData: [], errorDetail: { message: "boom" } });
const view = usePanelDataView({ panelType: "line", renderer: failing });
view.previewView.value = "data";
expect(view.isDataView.value).toBe(true);
expect(view.showDataOverlay.value).toBe(false);
failing.value = { filteredData: [], errorDetail: { message: "" } };
expect(view.showDataOverlay.value).toBe(true);
});
});
@@ -0,0 +1,50 @@
// 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 { computed, ref, toValue, type MaybeRefOrGetter } from "vue";
export type PanelPreviewView = "chart" | "data";
// Already tabular, or not query-driven, so a raw-rows view adds nothing.
const NO_DATA_VIEW_TYPES = ["table", "logs", "html", "markdown", "alert-list", "custom_chart"];
/** Per-viewer Chart | Data switch state for a rendered panel; never saved with the panel. */
export function usePanelDataView({
panelType,
renderer,
enabled = true,
}: {
panelType: MaybeRefOrGetter<string | undefined>;
renderer: MaybeRefOrGetter<any>;
enabled?: MaybeRefOrGetter<boolean>;
}) {
const previewView = ref<PanelPreviewView>("chart");
const showDataViewToggle = computed(
() => toValue(enabled) && !NO_DATA_VIEW_TYPES.includes(toValue(panelType) ?? ""),
);
const isDataView = computed(() => showDataViewToggle.value && previewView.value === "data");
// A failed query keeps its error on screen; its data may be the panel's error placeholder.
const showDataOverlay = computed(
() => isDataView.value && !toValue(renderer)?.errorDetail?.message,
);
// filteredData drops queries hidden in the legend, so the rows match the chart.
const rawData = computed(() => toValue(renderer)?.filteredData);
return { previewView, showDataViewToggle, isDataView, showDataOverlay, rawData };
}
@@ -170,7 +170,7 @@ describe("useFirstDataNotice", () => {
});
});
it("bounds the flag-off source to three limit-1 probes and 20-row pages", async () => {
it("bounds the flag-off source to one limit-1 probe per type and 20-row pages", async () => {
writeFirstDataRecord(ORG, { lastVisit: 1, hadData: false });
rows.metrics = [
...Array.from({ length: 30 }, (_, i) => ({ name: `_o2_internal_${i}`, stats: {} })),
@@ -179,7 +179,7 @@ describe("useFirstDataNotice", () => {
noticeOf();
await flushPromises();
const calls = nameList.mock.calls;
expect(calls.filter((c) => c[4] === 1)).toHaveLength(3);
expect(calls.filter((c) => c[4] === 1)).toHaveLength(4);
expect(calls.every((c) => c[4] === 1 || c[4] === 20)).toBe(true);
expect(calls.filter((c) => c[1] === "metrics" && c[4] === 20).map((c) => c[3])).toEqual([
0, 20,
@@ -29,9 +29,7 @@ import useStreams from "@/composables/useStreams";
import { gt } from "@/types/i18n";
import {
USER_DATA_STREAM_TYPES,
isInternalStreamName,
isUserDataStream,
isUserDataStreamType,
type UserDataStreamType,
} from "@/utils/internalStreams";
import { FIRST_EVENT_BUDGET, cadenceAt } from "./firstEventBudget";
@@ -49,11 +47,7 @@ import {
} from "./confirmQuery";
import { markDetectionSeen, orgHasUserStreams, readFirstDataRecord } from "./useFirstDataNotice";
// Profiles are watched only on their own setup page; "any" (no signal) still means logs, metrics and traces.
export type StreamSignal = UserDataStreamType | "profiles";
/** A stream the watch may count: user data of the watched type, never one O2 writes itself. */
const isWatchedStream = (name: string, type: string) =>
type === "profiles" ? !isInternalStreamName(name) : isUserDataStream(name, type);
export type StreamSignal = UserDataStreamType;
export type FirstEventState =
"idle" | "waiting" | "received" | "rejected" | "no-requests" | "stopped";
export type DiagnosisTrigger = "auto" | "troubleshoot";
@@ -418,7 +412,7 @@ export function useFirstEventWatch(
total = res.total;
rows.push(
...(res.list as StreamRow[]).filter(
(s) => s?.name?.includes(keyword) && isWatchedStream(s.name, s.stream_type ?? ctx.type),
(s) => s?.name?.includes(keyword) && isUserDataStream(s.name, s.stream_type ?? ctx.type),
),
);
if (offset + STREAM_PROBE_PAGE >= res.total) break;
@@ -481,8 +475,7 @@ export function useFirstEventWatch(
});
// the Vuex stream store holds the selected org's lists only, so a late answer for another org stays out
if (!list || stale(ctx) || ctx.org !== selectedOrg()) return;
// The Vuex stream store has no profiles list.
if (isUserDataStreamType(ctx.type)) setStreams(ctx.type, list);
setStreams(ctx.type, list);
};
const receive = async (ctx: PollContext, stream: string, c: Confirmation, sinceUs?: number) => {
@@ -652,7 +645,7 @@ export function useFirstEventWatch(
if (!list || stale(ctx)) return;
known.total = res.total;
for (const s of list) {
if (!known.names.has(s.name) && isWatchedStream(s.name, s.stream_type ?? type)) {
if (!known.names.has(s.name) && isUserDataStream(s.name, s.stream_type ?? type)) {
pending.set(s.name, s);
}
}
+15 -4
View File
@@ -897,11 +897,14 @@ describe("useStreams Composable", () => {
expect(result.list).toEqual([{ name: "log1" }, { name: "metric1" }]);
});
it("leaves cached profiles streams out of the all-streams payload", () => {
it("includes cached profiles streams in the all-streams payload", () => {
mockStore.state.streams.logs = { list: [{ name: "log1" }] };
(mockStore.state.streams as any).profiles = { list: [{ name: "prof1" }] };
expect(streamsInstance.getAllStreamsPayload().list).toEqual([{ name: "log1" }]);
expect(streamsInstance.getAllStreamsPayload().list).toEqual([
{ name: "log1" },
{ name: "prof1" },
]);
});
it("should add new streams only if they don't exist", () => {
@@ -1165,13 +1168,21 @@ describe("useStreams Composable", () => {
describe("Forced full read and fetched-at", () => {
it("a forced read of the whole list fetches every type, memory hits included", async () => {
queryClient.clear();
for (const type of ["logs", "metrics", "traces", "enrichment_tables", "index", "metadata"]) {
for (const type of [
"logs",
"metrics",
"traces",
"profiles",
"enrichment_tables",
"index",
"metadata",
]) {
(mockStore.state.streams as any)[type] = { list: [{ name: "cached" }] };
}
await streamsInstance.getStreams("all", false, false, true);
expect(StreamService.nameList).toHaveBeenCalledTimes(6);
expect(StreamService.nameList).toHaveBeenCalledTimes(7);
});
it("getStreamsFetchedAt reports the oldest type list, or nothing when none is cached", async () => {
+10 -2
View File
@@ -25,8 +25,16 @@ import { isUserDataStream } from "@/utils/internalStreams";
import { toast } from "@/lib/feedback/Toast/useToast";
import type { TranslateFn, I18nText } from "@/types/i18n";
// The "all" read fetches only these types; a profiles list is cached only when a page asks for that type.
const ALL_STREAM_TYPES = ["logs", "metrics", "traces", "enrichment_tables", "index", "metadata"];
// The "all" read fetches only these types.
const ALL_STREAM_TYPES = [
"logs",
"metrics",
"traces",
"profiles",
"enrichment_tables",
"index",
"metadata",
];
// Keep the HTTP status on the rejection: callers need it to tell a denial (403)
// apart from any other failure, and a bare Error would drop it.
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "أضفه إلى {client}",
"skillsLabel": "أضف مهارة {brand} (اختياري)",
"skillsHint": "يعلّم مساعد الذكاء الاصطناعي الخاص بك كيفية الاستعلام وإرسال التنبيهات وبناء لوحات المعلومات في {brand}، مما يجعله يستفيد أكثر من أدوات MCP. شغّله في مشروعك، ثم اختر الوكلاء الذين تريد التثبيت عليهم.",
"skillsHint": "يعلّم مساعد الذكاء الاصطناعي الخاص بك كيفية الاستعلام وإرسال التنبيهات وبناء لوحات المعلومات في {brand}، مما يجعله يستفيد أكثر من أدوات MCP. شغّله في مشروعك، وسيثبّتها في كل أداة ذكاء اصطناعي يجدها على جهازك.",
"skillsRepo": "اعرض المهارات على {host}"
},
"manageTokensBtnLabel": "إدارة الرموز",
@@ -9401,6 +9401,13 @@
"rowFields": "حقول الصف",
"valueFields": "حقول القيمة",
"noData": "لا توجد بيانات",
"previewView": "عرض المعاينة",
"previewChart": "مخطط",
"previewData": "بيانات",
"showRawData": "عرض البيانات الخام",
"showChart": "عرض المخطط",
"dataViewEmpty": "لم يتم إرجاع أي صفوف",
"dataViewCapped": "عرض أول {shown} من {total} صف",
"logExplorer": {
"title": "مستكشف السجلات",
"openInLogs": "فتح في السجلات",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Zu {client} hinzufügen",
"skillsLabel": "{brand}-Skill hinzufügen (optional)",
"skillsHint": "Bringt Ihrem KI-Assistenten bei, wie er in {brand} Abfragen durchführt, Alarme einrichtet und Dashboards erstellt, damit er mehr aus den MCP-Tools herausholt. Führen Sie es in Ihrem Projekt aus und wählen Sie dann die Agenten aus, in denen es installiert werden soll.",
"skillsHint": "Bringt Ihrem KI-Assistenten bei, wie er in {brand} Abfragen durchführt, Alarme einrichtet und Dashboards erstellt, damit er mehr aus den MCP-Tools herausholt. Führen Sie es in Ihrem Projekt aus; es installiert die Skills in jedes KI-Tool, das es auf Ihrem Rechner findet.",
"skillsRepo": "Die Skills auf {host} anzeigen"
},
"manageTokensBtnLabel": "Tokens verwalten",
@@ -9401,6 +9401,13 @@
"rowFields": "Zeilenfelder",
"valueFields": "Wertfelder",
"noData": "Keine Daten",
"previewView": "Vorschauansicht",
"previewChart": "Diagramm",
"previewData": "Daten",
"showRawData": "Rohdaten anzeigen",
"showChart": "Diagramm anzeigen",
"dataViewEmpty": "Keine Zeilen zurückgegeben",
"dataViewCapped": "Die ersten {shown} von {total} Zeilen werden angezeigt",
"logExplorer": {
"title": "Logs-Explorer",
"openInLogs": "In Logs öffnen",
+9 -2
View File
@@ -5467,7 +5467,7 @@
"functionAlreadyAssociated": "Function is already associated",
"atLeastOneCondition": "Please add at least one condition",
"fillAllConditionFields": "Please fill in all condition fields (column, operator and value are required)",
"selectExternalDestination": "Please select External destination from the list",
"selectExternalDestination": "Please select external destination from the list",
"backfillNumericRange": "Must be between {min} and {max}",
"selectValidTimeRange": "Please select a valid time range",
"startBeforeEndTime": "Start time must be before end time",
@@ -6206,7 +6206,7 @@
},
"installOneClick": "Add to {client}",
"skillsLabel": "Add the {brand} skill (optional)",
"skillsHint": "Teaches your AI assistant how to query, alert and build dashboards in {brand}, so it gets more out of the MCP tools. Run it in your project, then pick the agents to install to.",
"skillsHint": "Teaches your AI assistant how to query, alert and build dashboards in {brand}, so it gets more out of the MCP tools. Run it in your project; it installs them into every AI tool it finds on your machine.",
"skillsRepo": "View the skills on {host}"
},
"manageTokensBtnLabel": "Manage Tokens",
@@ -9420,6 +9420,13 @@
"rowFields": "Row Fields",
"valueFields": "Value Fields",
"noData": "No Data",
"previewView": "Preview view",
"previewChart": "Chart",
"previewData": "Data",
"showRawData": "Show raw data",
"showChart": "Show chart",
"dataViewEmpty": "No rows returned",
"dataViewCapped": "Showing the first {shown} of {total} rows",
"logExplorer": {
"title": "Logs Explorer",
"openInLogs": "Open in Logs",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Agregar a {client}",
"skillsLabel": "Añade la habilidad de {brand} (opcional)",
"skillsHint": "Enseña a tu Asistente de IA a consultar, alertar y crear dashboards en {brand}, para que aproveche mejor las herramientas MCP. Ejecútala en tu proyecto y luego elige los agentes en los que instalarla.",
"skillsHint": "Enseña a tu Asistente de IA a consultar, alertar y crear dashboards en {brand}, para que aproveche mejor las herramientas MCP. Ejecútala en tu proyecto; las instala en cada herramienta de IA que encuentre en tu equipo.",
"skillsRepo": "Ver las habilidades en {host}"
},
"manageTokensBtnLabel": "Administrar los tokens",
@@ -9401,6 +9401,13 @@
"rowFields": "Campos de fila",
"valueFields": "Campos de valor",
"noData": "Sin datos",
"previewView": "Vista previa",
"previewChart": "Gráfico",
"previewData": "Datos",
"showRawData": "Mostrar datos sin procesar",
"showChart": "Mostrar gráfico",
"dataViewEmpty": "No se devolvieron filas",
"dataViewCapped": "Mostrando las primeras {shown} de {total} filas",
"logExplorer": {
"title": "Explorador de logs",
"openInLogs": "Abrir en Logs",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Ajouter à {client}",
"skillsLabel": "Ajouter la compétence {brand} (facultatif)",
"skillsHint": "Apprend à votre assistante IA à interroger, à alerter et à créer des tableaux de bord dans {brand}, afin qu'elle tire le meilleur parti des outils MCP. Exécutez-la dans votre projet, puis choisissez les agents dans lesquels l'installer.",
"skillsHint": "Apprend à votre assistante IA à interroger, à alerter et à créer des tableaux de bord dans {brand}, afin qu'elle tire le meilleur parti des outils MCP. Exécutez-la dans votre projet ; elle les installe dans chaque outil d'IA qu'elle trouve sur votre machine.",
"skillsRepo": "Voir les compétences sur {host}"
},
"manageTokensBtnLabel": "Gérer les jetons",
@@ -9401,6 +9401,13 @@
"rowFields": "Champs de ligne",
"valueFields": "Champs de valeur",
"noData": "Aucune donnée",
"previewView": "Vue de l'aperçu",
"previewChart": "Graphique",
"previewData": "Données",
"showRawData": "Afficher les données brutes",
"showChart": "Afficher le graphique",
"dataViewEmpty": "Aucune ligne renvoyée",
"dataViewCapped": "Affichage des {shown} premières lignes sur {total}",
"logExplorer": {
"title": "Explorateur de Logs",
"openInLogs": "Ouvrir dans Logs",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Aggiungi a {client}",
"skillsLabel": "Aggiungi la skill {brand} (opzionale)",
"skillsHint": "Insegna al tuo Assistente AI come eseguire query, creare avvisi e dashboard in {brand}, così può sfruttare al meglio gli strumenti MCP. Eseguilo nel tuo progetto, poi scegli gli agenti su cui installarlo.",
"skillsHint": "Insegna al tuo Assistente AI come eseguire query, creare avvisi e dashboard in {brand}, così può sfruttare al meglio gli strumenti MCP. Eseguilo nel tuo progetto: le installa in ogni strumento AI che trova sul tuo computer.",
"skillsRepo": "Visualizza le skill su {host}"
},
"manageTokensBtnLabel": "Gestisci i token",
@@ -9401,6 +9401,13 @@
"rowFields": "Campi riga",
"valueFields": "Campi valore",
"noData": "Nessun dato",
"previewView": "Vista anteprima",
"previewChart": "Grafico",
"previewData": "Dati",
"showRawData": "Mostra dati grezzi",
"showChart": "Mostra grafico",
"dataViewEmpty": "Nessuna riga restituita",
"dataViewCapped": "Visualizzazione delle prime {shown} righe su {total}",
"logExplorer": {
"title": "Esplora log",
"openInLogs": "Apri nei log",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "{client} に追加",
"skillsLabel": "{brand} スキルを追加(任意)",
"skillsHint": "{brand} でのクエリ、アラート、ダッシュボードの作成方法を AI アシスタントに教えることで、MCP ツールをより活用できるようになります。プロジェクトで実行し、インストール先のエージェントを選択してください。",
"skillsHint": "{brand} でのクエリ、アラート、ダッシュボードの作成方法を AI アシスタントに教えることで、MCP ツールをより活用できるようになります。プロジェクトで実行すると、マシン上で見つかったすべての AI ツールにインストールされます。",
"skillsRepo": "{host} でスキルを表示"
},
"manageTokensBtnLabel": "トークンの管理",
@@ -9401,6 +9401,13 @@
"rowFields": "行フィールド",
"valueFields": "値フィールド",
"noData": "データなし",
"previewView": "プレビュー表示",
"previewChart": "チャート",
"previewData": "データ",
"showRawData": "生データを表示",
"showChart": "チャートを表示",
"dataViewEmpty": "返された行はありません",
"dataViewCapped": "{total} 行のうち最初の {shown} 行を表示しています",
"logExplorer": {
"title": "ログエクスプローラー",
"openInLogs": "ログで開く",
+9 -2
View File
@@ -5446,7 +5446,7 @@
"functionAlreadyAssociated": "함수가 이미 연결되어 있습니다",
"atLeastOneCondition": "조건을 하나 이상 추가하세요",
"fillAllConditionFields": "모든 조건 필드를 입력하세요(열, 연산자, 값은 필수입니다)",
"selectExternalDestination": "목록에서 외부 대상을 선택하세요.",
"selectExternalDestination": "목록에서 외부 대상을 선택하세요",
"backfillNumericRange": "{min}에서 {max} 사이여야 합니다",
"selectValidTimeRange": "유효한 시간 범위를 선택하세요",
"startBeforeEndTime": "시작 시간은 종료 시간 이전이어야 합니다.",
@@ -6185,7 +6185,7 @@
},
"installOneClick": "{client}에 추가",
"skillsLabel": "{brand} 스킬 추가(선택 사항)",
"skillsHint": "{brand}에서 쿼리하고, 알림을 설정하고, 대시보드를 구축하는 방법을 AI 어시스턴트에게 가르쳐 주므로 MCP 도구를 더 효과적으로 활용할 수 있습니다. 프로젝트에서 실행한 다음, 설치할 에이전트를 선택하세요.",
"skillsHint": "{brand}에서 쿼리하고, 알림을 설정하고, 대시보드를 구축하는 방법을 AI 어시스턴트에게 가르쳐 주므로 MCP 도구를 더 효과적으로 활용할 수 있습니다. 프로젝트에서 실행하면 컴퓨터에서 찾은 모든 AI 도구에 설치됩니다.",
"skillsRepo": "{host}에서 스킬 보기"
},
"manageTokensBtnLabel": "토큰 관리",
@@ -9401,6 +9401,13 @@
"rowFields": "행 필드",
"valueFields": "값 필드",
"noData": "데이터 없음",
"previewView": "미리보기 보기",
"previewChart": "차트",
"previewData": "데이터",
"showRawData": "원시 데이터 표시",
"showChart": "차트 표시",
"dataViewEmpty": "반환된 행이 없습니다",
"dataViewCapped": "{total}개 행 중 처음 {shown}개 표시 중",
"logExplorer": {
"title": "로그 탐색기",
"openInLogs": "로그에서 열기",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Toevoegen aan {client}",
"skillsLabel": "Voeg de {brand}-vaardigheid toe (optioneel)",
"skillsHint": "Leert je AI-assistent hoe hij query's uitvoert, alarmen instelt en dashboards bouwt in {brand}, zodat je meer uit de MCP-tools haalt. Voer het uit in je project en kies vervolgens de agents waarin je het wilt installeren.",
"skillsHint": "Leert je AI-assistent hoe hij query's uitvoert, alarmen instelt en dashboards bouwt in {brand}, zodat je meer uit de MCP-tools haalt. Voer het uit in je project; het installeert ze in elke AI-tool die het op je computer vindt.",
"skillsRepo": "Bekijk de vaardigheden op {host}"
},
"manageTokensBtnLabel": "Tokens beheren",
@@ -9401,6 +9401,13 @@
"rowFields": "Rijvelden",
"valueFields": "Waardevelden",
"noData": "Geen gegevens",
"previewView": "Voorbeeldweergave",
"previewChart": "Grafiek",
"previewData": "Gegevens",
"showRawData": "Ruwe gegevens tonen",
"showChart": "Grafiek tonen",
"dataViewEmpty": "Geen rijen geretourneerd",
"dataViewCapped": "De eerste {shown} van {total} rijen worden getoond",
"logExplorer": {
"title": "Logverkenner",
"openInLogs": "Openen in Logs",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Dodaj do {client}",
"skillsLabel": "Dodaj umiejętność {brand} (opcjonalnie)",
"skillsHint": "Uczy Twojego asystenta AI, jak odpytywać dane, konfigurować alerty i tworzyć pulpity w {brand}, dzięki czemu lepiej wykorzysta narzędzia MCP. Uruchom ją w swoim projekcie, a następnie wybierz agentów, u których chcesz ją zainstalować.",
"skillsHint": "Uczy Twojego asystenta AI, jak odpytywać dane, konfigurować alerty i tworzyć pulpity w {brand}, dzięki czemu lepiej wykorzysta narzędzia MCP. Uruchom ją w swoim projekcie; zainstaluje je w każdym narzędziu AI, które znajdzie na Twoim komputerze.",
"skillsRepo": "Zobacz umiejętności na {host}"
},
"manageTokensBtnLabel": "Zarządzaj tokenami",
@@ -9401,6 +9401,13 @@
"rowFields": "Pola wierszy",
"valueFields": "Pola wartości",
"noData": "Brak danych",
"previewView": "Widok podglądu",
"previewChart": "Wykres",
"previewData": "Dane",
"showRawData": "Pokaż surowe dane",
"showChart": "Pokaż wykres",
"dataViewEmpty": "Nie zwrócono żadnych wierszy",
"dataViewCapped": "Wyświetlanie pierwszych {shown} z {total} wierszy",
"logExplorer": {
"title": "Eksplorator logów",
"openInLogs": "Otwórz w logach",
+9 -2
View File
@@ -5446,7 +5446,7 @@
"functionAlreadyAssociated": "A função já está associada",
"atLeastOneCondition": "Adicione pelo menos uma condição",
"fillAllConditionFields": "Preencha todos os campos da condição (coluna, operador e valor são obrigatórios)",
"selectExternalDestination": "Selecione o destino externo da lista",
"selectExternalDestination": "Selecione um destino externo da lista",
"backfillNumericRange": "Deve estar entre {min} e {max}",
"selectValidTimeRange": "Selecione um intervalo de tempo válido",
"startBeforeEndTime": "A hora de início deve ser antes da hora de fim",
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Adicione a {client}",
"skillsLabel": "Adicionar a skill {brand} (opcional)",
"skillsHint": "Ensina o seu Assistente de IA a consultar, alertar e criar dashboards no {brand}, para aproveitar melhor as ferramentas MCP. Execute-a no seu projeto e depois escolha os agentes onde instalá-la.",
"skillsHint": "Ensina o seu Assistente de IA a consultar, alertar e criar dashboards no {brand}, para aproveitar melhor as ferramentas MCP. Execute-a no seu projeto; instala-as em cada ferramenta de IA que encontrar no seu computador.",
"skillsRepo": "Ver as skills em {host}"
},
"manageTokensBtnLabel": "Gerir tokens",
@@ -9401,6 +9401,13 @@
"rowFields": "Campos de linha",
"valueFields": "Campos de valor",
"noData": "Sem dados",
"previewView": "Vista de pré-visualização",
"previewChart": "Gráfico",
"previewData": "Dados",
"showRawData": "Mostrar dados brutos",
"showChart": "Mostrar gráfico",
"dataViewEmpty": "Nenhuma linha devolvida",
"dataViewCapped": "A mostrar as primeiras {shown} de {total} linhas",
"logExplorer": {
"title": "Explorador de Logs",
"openInLogs": "Abrir em Logs",
+9 -2
View File
@@ -5446,7 +5446,7 @@
"functionAlreadyAssociated": "Функция уже связана",
"atLeastOneCondition": "Добавьте хотя бы одно условие",
"fillAllConditionFields": "Заполните все поля условия (столбец, оператор и значение обязательны)",
"selectExternalDestination": "Выберите внешнего получателя из списка",
"selectExternalDestination": "Пожалуйста, выберите внешнего получателя из списка",
"backfillNumericRange": "Должно быть от {min} до {max}",
"selectValidTimeRange": "Выберите допустимый временной диапазон",
"startBeforeEndTime": "Время начала должно быть раньше времени окончания",
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Добавьте в {client}",
"skillsLabel": "Добавьте навык {brand} (необязательно)",
"skillsHint": "Учит ваш ИИ-ассистент запрашивать данные, настраивать оповещения и создавать дашборды в {brand}, чтобы он эффективнее использовал инструменты MCP. Запустите его в своем проекте, затем выберите агентов, куда установить.",
"skillsHint": "Учит ваш ИИ-ассистент запрашивать данные, настраивать оповещения и создавать дашборды в {brand}, чтобы он эффективнее использовал инструменты MCP. Запустите его в своем проекте: он установит их во все ИИ-инструменты, которые найдет на вашем компьютере.",
"skillsRepo": "Просмотреть навыки на {host}"
},
"manageTokensBtnLabel": "Управление токенами",
@@ -9401,6 +9401,13 @@
"rowFields": "Поля строк",
"valueFields": "Поля значений",
"noData": "Нет данных",
"previewView": "Режим предпросмотра",
"previewChart": "График",
"previewData": "Данные",
"showRawData": "Показать исходные данные",
"showChart": "Показать график",
"dataViewEmpty": "Строки не возвращены",
"dataViewCapped": "Показаны первые {shown} из {total} строк",
"logExplorer": {
"title": "Просмотр логов",
"openInLogs": "Открыть в логах",
+8 -1
View File
@@ -5446,7 +5446,7 @@
"functionAlreadyAssociated": "Function is already associated",
"atLeastOneCondition": "Please add at least one condition",
"fillAllConditionFields": "Please fill in all condition fields (column, operator and value are required)",
"selectExternalDestination": "Please select External destination from the list",
"selectExternalDestination": "โปรดเลือกปลายทางภายนอกจากรายการ",
"backfillNumericRange": "Must be between {min} and {max}",
"selectValidTimeRange": "Please select a valid time range",
"startBeforeEndTime": "Start time must be before end time",
@@ -9399,6 +9399,13 @@
"rowFields": "Row Fields",
"valueFields": "Value Fields",
"noData": "No Data",
"previewView": "มุมมองตัวอย่าง",
"previewChart": "แผนภูมิ",
"previewData": "ข้อมูล",
"showRawData": "แสดงข้อมูลดิบ",
"showChart": "แสดงแผนภูมิ",
"dataViewEmpty": "ไม่มีแถวที่ส่งกลับมา",
"dataViewCapped": "แสดง {shown} แถวแรกจากทั้งหมด {total} แถว",
"logExplorer": {
"title": "Logs Explorer",
"openInLogs": "Open in Logs",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "{client} öğesine ekle",
"skillsLabel": "{brand} becerisini ekleyin (isteğe bağlı)",
"skillsHint": "Yapay Zeka Asistanınıza {brand} içinde nasıl sorgu yapacağını, uyarı oluşturacağını ve gösterge panoları oluşturacağını öğretir, böylece MCP araçlarından daha fazla yararlanır. Projenizde çalıştırın, ardından hangi aracılara yükleyeceğinizi seçin.",
"skillsHint": "Yapay Zeka Asistanınıza {brand} içinde nasıl sorgu yapacağını, uyarı oluşturacağını ve gösterge panoları oluşturacağını öğretir, böylece MCP araçlarından daha fazla yararlanır. Projenizde çalıştırın; makinenizde bulduğu her yapay zeka aracına yükler.",
"skillsRepo": "{host} üzerindeki becerileri görüntüleyin"
},
"manageTokensBtnLabel": "Token'ları Yönet",
@@ -9401,6 +9401,13 @@
"rowFields": "Satır Alanları",
"valueFields": "Değer Alanları",
"noData": "Veri Yok",
"previewView": "Önizleme görünümü",
"previewChart": "Grafik",
"previewData": "Veri",
"showRawData": "Ham veriyi göster",
"showChart": "Grafiği göster",
"dataViewEmpty": "Hiç satır döndürülmedi",
"dataViewCapped": "{total} satırın ilk {shown} tanesi gösteriliyor",
"logExplorer": {
"title": "Günlük Gezgini",
"openInLogs": "Günlüklerde Aç",
+9 -2
View File
@@ -5446,7 +5446,7 @@
"functionAlreadyAssociated": "Hàm đã được liên kết",
"atLeastOneCondition": "Vui lòng thêm ít nhất một điều kiện",
"fillAllConditionFields": "Vui lòng điền tất cả các trường điều kiện (cột, toán tử và giá trị là bắt buộc)",
"selectExternalDestination": "Vui lòng chọn Đích bên ngoài từ danh sách",
"selectExternalDestination": "Vui lòng chọn đích bên ngoài từ danh sách",
"backfillNumericRange": "Phải nằm trong khoảng từ {min} đến {max}",
"selectValidTimeRange": "Vui lòng chọn một phạm vi thời gian hợp lệ",
"startBeforeEndTime": "Thời gian bắt đầu phải trước thời gian kết thúc",
@@ -6185,7 +6185,7 @@
},
"installOneClick": "Thêm vào {client}",
"skillsLabel": "Thêm kỹ năng {brand} (không bắt buộc)",
"skillsHint": "Dạy Trợ lý AI của bạn cách truy vấn, cảnh báo và tạo bảng điều khiển trong {brand}, để tận dụng tốt hơn các công cụ MCP. Chạy nó trong dự án của bạn, sau đó chọn các agent để cài đặt.",
"skillsHint": "Dạy Trợ lý AI của bạn cách truy vấn, cảnh báo và tạo bảng điều khiển trong {brand}, để tận dụng tốt hơn các công cụ MCP. Chạy nó trong dự án của bạn; nó sẽ cài đặt vào mọi công cụ AI tìm thấy trên máy của bạn.",
"skillsRepo": "Xem các kỹ năng trên {host}"
},
"manageTokensBtnLabel": "Quản lý mã thông báo",
@@ -9401,6 +9401,13 @@
"rowFields": "Trường hàng",
"valueFields": "Trường giá trị",
"noData": "Không có dữ liệu",
"previewView": "Chế độ xem trước",
"previewChart": "Biểu đồ",
"previewData": "Dữ liệu",
"showRawData": "Hiển thị dữ liệu thô",
"showChart": "Hiển thị biểu đồ",
"dataViewEmpty": "Không có hàng nào được trả về",
"dataViewCapped": "Đang hiển thị {shown} hàng đầu tiên trong số {total} hàng",
"logExplorer": {
"title": "Trình khám phá nhật ký",
"openInLogs": "Mở trong Nhật ký",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "添加到{client}",
"skillsLabel": "添加 {brand} 技能(可选)",
"skillsHint": "教您的人工智能助手如何在 {brand} 中进行查询、告警和构建仪表盘,从而更充分地利用 MCP 工具。在您的项目中运行它,然后选择要安装到的智能体。",
"skillsHint": "教您的人工智能助手如何在 {brand} 中进行查询、告警和构建仪表盘,从而更充分地利用 MCP 工具。在您的项目中运行它,它会安装到您机器上找到的每个 AI 工具中。",
"skillsRepo": "在 {host} 上查看技能"
},
"manageTokensBtnLabel": "管理令牌",
@@ -9401,6 +9401,13 @@
"rowFields": "行字段",
"valueFields": "值字段",
"noData": "无数据",
"previewView": "预览视图",
"previewChart": "图表",
"previewData": "数据",
"showRawData": "显示原始数据",
"showChart": "显示图表",
"dataViewEmpty": "未返回任何行",
"dataViewCapped": "显示 {total} 行中的前 {shown} 行",
"logExplorer": {
"title": "日志浏览器",
"openInLogs": "在日志中打开",
+8 -1
View File
@@ -6185,7 +6185,7 @@
},
"installOneClick": "新增至 {client}",
"skillsLabel": "新增 {brand} 技能(選填)",
"skillsHint": "教導您的 AI 助手如何在 {brand} 中查詢、設定警示及建立儀表板,讓它能更充分利用 MCP 工具。在您的專案中執行它,然後選擇要安裝到的代理程式。",
"skillsHint": "教導您的 AI 助手如何在 {brand} 中查詢、設定警示及建立儀表板,讓它能更充分利用 MCP 工具。在您的專案中執行它,它會安裝到您電腦上找到的每個 AI 工具中。",
"skillsRepo": "在 {host} 上檢視技能"
},
"manageTokensBtnLabel": "管理權杖",
@@ -9396,6 +9396,13 @@
"rowFields": "列欄位",
"valueFields": "值欄位",
"noData": "無資料",
"previewView": "預覽檢視",
"previewChart": "圖表",
"previewData": "資料",
"showRawData": "顯示原始資料",
"showChart": "顯示圖表",
"dataViewEmpty": "未傳回任何資料列",
"dataViewCapped": "顯示 {total} 列中的前 {shown} 列",
"logExplorer": {
"title": "日誌瀏覽器",
"openInLogs": "在日誌中開啟",
+25 -21
View File
@@ -21,6 +21,10 @@ import i18n from "@/locales";
import store from "@/test/unit/helpers/store";
import router from "@/test/unit/helpers/router";
// The default 1 s waitFor flakes when a coverage-instrumented shard mounts the whole page.
vi.setConfig({ testTimeout: 15_000 });
const waitFor = (assertion: () => unknown) => vi.waitFor(assertion, { timeout: 10_000 });
const node = document.createElement("div");
node.setAttribute("id", "app");
document.body.appendChild(node);
@@ -253,7 +257,7 @@ describe("Traces page load states", () => {
it("shows the onboarding once the stream list settles with no streams", async () => {
mockGetStreams.mockResolvedValue({ list: [] });
wrapper = mount(Index, { attachTo: node, global: globalOptions });
await vi.waitFor(() => expect(searchObj.loadingStream).toBe(false));
await waitFor(() => expect(searchObj.loadingStream).toBe(false));
await nextTick();
expect(wrapper.find('[data-test="traces-no-streams-in-org-text"]').exists()).toBe(true);
@@ -263,7 +267,7 @@ describe("Traces page load states", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
mockLoadTracesParser.mockRejectedValue(new Error("chunk"));
wrapper = mount(Index, { attachTo: node, global: globalOptions });
await vi.waitFor(() => expect(searchObj.loadingStream).toBe(false));
await waitFor(() => expect(searchObj.loadingStream).toBe(false));
expect(mockGetStreams).toHaveBeenCalledTimes(1);
});
@@ -273,7 +277,7 @@ describe("Traces page load states", () => {
const mountSearching = async () => {
setRouteQuery({ stream: "default" });
wrapper = mount(Index, { attachTo: node, global: globalOptions });
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
};
const countHits = (n: number) => ({
content: { results: { hits: [{ trace_count: n, span_count: n, error_count: 1 }] } },
@@ -425,7 +429,7 @@ describe("Traces page load states", () => {
describe("kept alive behind a trace detail page", () => {
const settleSearch = async () => {
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
lastCall("main")[1].complete(null);
lastCall("count")[1].complete(null);
};
@@ -504,13 +508,13 @@ describe("Traces page load states", () => {
await leaveAndReturn(show, { stream: "default", period: "1h" });
expect(mockGetStreams).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
});
it("cancels a search in flight when it leaves and runs it again on return", async () => {
setRouteQuery({ stream: "default" });
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
const inFlightId = lastCall("main")[0].traceId;
show.value = false;
@@ -527,7 +531,7 @@ describe("Traces page load states", () => {
it("re-runs a search it left mid-run on the page the user left", async () => {
setRouteQuery({ stream: "default" });
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
searchObj.data.resultGrid.currentPage = 2;
show.value = false;
@@ -546,7 +550,7 @@ describe("Traces page load states", () => {
it("re-fetches only the count when it left while the count was running", async () => {
setRouteQuery({ stream: "default" });
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
lastCall("main")[1].complete(null);
mockFetch.mockClear();
@@ -560,7 +564,7 @@ describe("Traces page load states", () => {
try {
setRouteQuery({ stream: "default" });
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
lastCall("main")[1].complete(null);
mockFetch.mockClear();
store.state.zoConfig.auto_query_enabled = true;
@@ -568,7 +572,7 @@ describe("Traces page load states", () => {
await leaveAndReturn(show, { period: "1h" });
await vi.waitFor(() => expect(searchObj.data.queryResults.countPending).toBe(false));
await waitFor(() => expect(searchObj.data.queryResults.countPending).toBe(false));
expect(mockFetch).not.toHaveBeenCalled();
} finally {
delete store.state.zoConfig.auto_query_enabled;
@@ -599,7 +603,7 @@ describe("Traces page load states", () => {
show.value = true;
await flushPromises();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
expect(mockGetStreams).toHaveBeenCalledTimes(1);
expect(searchObj.data.stream.selectedStream.value).toBe("default");
});
@@ -609,7 +613,7 @@ describe("Traces page load states", () => {
const streams = deferred<typeof streamList>();
mockGetStreams.mockReturnValueOnce(streams.promise);
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
show.value = false;
await flushPromises();
@@ -622,7 +626,7 @@ describe("Traces page load states", () => {
show.value = true;
await flushPromises();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
expect(mockGetStreams).toHaveBeenCalledTimes(2);
expect(searchObj.data.stream.selectedStream.value).toBe("default");
});
@@ -634,7 +638,7 @@ describe("Traces page load states", () => {
const streams = deferred<typeof streamList>();
mockGetStreams.mockReturnValueOnce(streams.promise);
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
show.value = false;
await flushPromises();
@@ -644,7 +648,7 @@ describe("Traces page load states", () => {
show.value = true;
await flushPromises();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
lastCall("main")[1].complete(null);
await flushPromises();
@@ -679,7 +683,7 @@ describe("Traces page load states", () => {
const streams = deferred<typeof streamList>();
mockGetStreams.mockReturnValueOnce(streams.promise);
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(1));
show.value = false;
await flushPromises();
@@ -688,7 +692,7 @@ describe("Traces page load states", () => {
mockTakeLanding.mockReturnValueOnce({ run: true });
show.value = true;
await flushPromises();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
lastCall("main")[1].complete(null);
await flushPromises();
@@ -700,7 +704,7 @@ describe("Traces page load states", () => {
const schema = deferred<(typeof streamList.list)[number]>();
mockGetStream.mockReturnValueOnce(schema.promise);
const { show } = mountKeptAlive();
await vi.waitFor(() => expect(mockGetStream).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockGetStream).toHaveBeenCalledTimes(1));
show.value = false;
await flushPromises();
@@ -716,7 +720,7 @@ describe("Traces page load states", () => {
show.value = true;
await flushPromises();
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
expect(searchObj.data.stream.selectedStreamFields.length).toBeGreaterThan(0);
});
@@ -732,7 +736,7 @@ describe("Traces page load states", () => {
await flushPromises();
setRouteQuery({ stream: "default", filter: "service_name='api'" });
show.value = true;
await vi.waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(2));
await waitFor(() => expect(mockGetStreams).toHaveBeenCalledTimes(2));
show.value = false;
await flushPromises();
streams.resolve(streamList);
@@ -760,7 +764,7 @@ describe("Traces page load states", () => {
it("drops a late batch from a search cancelled by leaving the page", async () => {
setRouteQuery({ stream: "default" });
wrapper = mount(Index, { attachTo: node, global: globalOptions });
await vi.waitFor(() => expect(callsOfType("main")).toHaveLength(1));
await waitFor(() => expect(callsOfType("main")).toHaveLength(1));
const lateBatch = lastCall("main")[1].data;
wrapper.unmount();
+1
View File
@@ -82,6 +82,7 @@ describe("internalStreams", () => {
expect(isUserDataStream("default", "logs")).toBe(true);
expect(isUserDataStream("up", "metrics")).toBe(true);
expect(isUserDataStream("default", "traces")).toBe(true);
expect(isUserDataStream("default", "profiles")).toBe(true);
expect(isUserDataStream("_rumdata", "logs")).toBe(true);
expect(isUserDataStream("_rumlog", "logs")).toBe(true);
expect(isUserDataStream("_sessionreplay", "logs")).toBe(true);
+2 -2
View File
@@ -33,7 +33,7 @@ export const INTERNAL_STREAM_NAMES = [
export const INTERNAL_STREAM_PREFIXES = ["_o2_"] as const;
/** RUM streams (_rumdata, _rumlog, _sessionreplay) are logs, so they count as user data. */
export const USER_DATA_STREAM_TYPES = ["logs", "metrics", "traces"] as const;
export const USER_DATA_STREAM_TYPES = ["logs", "metrics", "traces", "profiles"] as const;
export type UserDataStreamType = (typeof USER_DATA_STREAM_TYPES)[number];
@@ -47,7 +47,7 @@ export function isUserDataStreamType(streamType: string): streamType is UserData
return (USER_DATA_STREAM_TYPES as readonly string[]).includes(streamType);
}
/** True only for a logs, metrics or traces stream that O2 does not write itself. */
/** True only for a logs, metrics, traces or profiles stream that O2 does not write itself. */
export function isUserDataStream(name: string, streamType: string): boolean {
return isUserDataStreamType(streamType) && !isInternalStreamName(name);
}
+3 -2
View File
@@ -128,8 +128,9 @@ describe("OR-grouped filters", () => {
expect(distributeUnionRanges(`avg(${u})`)).toBe(
`avg({__name__="m",a="${long}"} or {__name__="m",b="2"})`,
);
expect(performance.now() - started).toBeLessThan(1000);
});
// Wall-clock limits flake on loaded shards (1.6 s seen); only a severe stall should fail.
expect(performance.now() - started).toBeLessThan(5000);
}, 30_000);
it("every card query and breakdown runs with the union distributed", () => {
const counter = getMetricDefaults("http_requests_total", "Counter", undefined, {
+1 -1
View File
@@ -1013,7 +1013,7 @@ describe("OverviewTab", () => {
it("should ask nothing when the first-data notice already probed every type empty", async () => {
const org = store.state.selectedOrganization.identifier;
for (const type of ["logs", "metrics", "traces"]) {
for (const type of ["logs", "metrics", "traces", "profiles"]) {
queryClient.setQueryData(streamProbeQuery(org, type).queryKey, { list: [], total: 0 });
}
+2
View File
@@ -3,6 +3,7 @@ import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import type { ConfigEnv } from 'vite'
import type { UserConfig } from 'vite'
import viteConfig from './vite.config'
import UiShardSequencer from './scripts/ui-shard-sequencer'
// Convert the vite config to a plain object if it's a function
const viteConfigObj = typeof viteConfig === 'function'
@@ -84,6 +85,7 @@ export default mergeConfig(
// Suppress all console output during tests except test results
silent: false,
reporters: ['default'],
...(process.env.UI_SHARD_BALANCE === '1' && { sequence: { sequencer: UiShardSequencer } }),
// Suppress all console output (both stderr and stdout)
onConsoleLog: (log: string, type: 'stdout' | 'stderr') => {
// Return false to prevent all console logs from being printed