mirror of
https://github.com/openobserve/openobserve.git
synced 2026-11-05 08:09:22 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 = || {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "$@"
|
||||
|
||||
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "فتح في السجلات",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ログで開く",
|
||||
|
||||
@@ -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": "로그에서 열기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Открыть в логах",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ç",
|
||||
|
||||
@@ -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ý",
|
||||
|
||||
@@ -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": "在日志中打开",
|
||||
|
||||
@@ -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": "在日誌中開啟",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user