From ed1a5bd6151565f202506cadbac9d1d3ddbea71c Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:46:11 +0800 Subject: [PATCH] chore(deps): pnpm 12, and ClickHouse row validation on pnpm 12.9.1 in package.json, CI and Dockerfile.web; the lockfile gains the pnpm 12 binary document (14 per-platform entries), lockfileVersion unchanged. The ClickHouse client keeps the crate's row validation on. Every read was checked: DESCRIBE of all 68 query sites and their variants against the Rust rows on 26.8 and 26.9, and a new integration test that seeds complete rows and calls each reading endpoint in each shape. That found and fixes three bugs: route history decoded UInt32 and Nullable quantiles into i64/f64 and came back empty; formatDateTime's %M is the month name, so trace and log-body timestamps read "04:August:52"; and the trace's app-log query compared the String alias created_at with a DateTime, failed, and showed no app events. The test harness now builds its client with the production create_client. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- crates/common/src/clickhouse_client.rs | 12 +- crates/server/src/handlers/gateway_logs.rs | 2 +- crates/server/src/handlers/mcp_logs.rs | 2 +- crates/server/src/handlers/models.rs | 12 +- crates/server/src/handlers/trace.rs | 14 +- crates/test-support/src/ch.rs | 20 +- .../tests/clickhouse_row_types.rs | 429 ++++++++++++++++++ deploy/docker/Dockerfile.web | 13 +- web/package.json | 2 +- web/pnpm-lock.yaml | 158 +++++++ 11 files changed, 632 insertions(+), 34 deletions(-) create mode 100644 crates/test-support/tests/clickhouse_row_types.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a88f425..abe08f1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,7 +180,7 @@ jobs: with: # Pinned — matches `packageManager` in web/package.json. # Bump together with that field, never alone. - version: 11.28.4 + version: 12.9.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 diff --git a/crates/common/src/clickhouse_client.rs b/crates/common/src/clickhouse_client.rs index 38c3fa91..614e480e 100644 --- a/crates/common/src/clickhouse_client.rs +++ b/crates/common/src/clickhouse_client.rs @@ -46,13 +46,11 @@ pub fn create_client(config: &AuditConfig) -> Option { .with_url(url) .with_database(&config.clickhouse_db) .with_product_info("think-watch", env!("CARGO_PKG_VERSION")) - .with_setting(NETWORK_COMPRESSION_METHOD.0, NETWORK_COMPRESSION_METHOD.1) - // Plain `RowBinary`, as before clickhouse 0.14. Validation reads - // results as `RowBinaryWithNamesAndTypes` and fails a query whose - // row struct differs from the column types at all, i64 against - // UInt64 included. Not every query has been checked against - // that yet; until they have, it stays off. - .with_validation(false); + .with_setting(NETWORK_COMPRESSION_METHOD.0, NETWORK_COMPRESSION_METHOD.1); + // Row validation stays at the crate's default, on: each result is + // checked against the Rust row it lands in, so a type that drifts + // fails the query instead of decoding into garbage. Every read is + // exercised with complete rows by tests/clickhouse_row_types.rs. if let Some(ref user) = config.clickhouse_user { client = client.with_user(user); diff --git a/crates/server/src/handlers/gateway_logs.rs b/crates/server/src/handlers/gateway_logs.rs index 03272781..d52f5e2c 100644 --- a/crates/server/src/handlers/gateway_logs.rs +++ b/crates/server/src/handlers/gateway_logs.rs @@ -402,7 +402,7 @@ pub async fn get_gateway_log_body( let row: Option = ch .query( "SELECT id, trace_id, user_id, model_id, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ request_body, response_body, request_body_bytes, \ response_body_bytes, body_capture_status \ FROM gateway_logs WHERE id = ? LIMIT 1", diff --git a/crates/server/src/handlers/mcp_logs.rs b/crates/server/src/handlers/mcp_logs.rs index dbf3c88b..962108e7 100644 --- a/crates/server/src/handlers/mcp_logs.rs +++ b/crates/server/src/handlers/mcp_logs.rs @@ -272,7 +272,7 @@ pub async fn get_mcp_log_body( let row: Option = ch .query( "SELECT id, trace_id, user_id, server_id, server_name, tool_name, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ tool_arguments, tool_result, arguments_bytes, result_bytes, \ body_capture_status \ FROM mcp_logs WHERE id = ? LIMIT 1", diff --git a/crates/server/src/handlers/models.rs b/crates/server/src/handlers/models.rs index dc1da4a6..5bad2cfe 100644 --- a/crates/server/src/handlers/models.rs +++ b/crates/server/src/handlers/models.rs @@ -1304,7 +1304,7 @@ pub async fn get_route_history( // provisioned yet, we fall back to an empty response — the // sparkline is a hint, not load-bearing. let sql = format!( - "SELECT toUnixTimestamp(toStartOfMinute(created_at)) AS bucket_ts, \ + "SELECT toInt64(toUnixTimestamp(toStartOfMinute(created_at))) AS bucket_ts, \ quantile(0.50)(latency_ms) AS p50, \ quantile(0.95)(latency_ms) AS p95, \ count() AS requests, \ @@ -1318,11 +1318,13 @@ pub async fn get_route_history( ORDER BY bucket_ts" ); + // `latency_ms` is Nullable, so its quantiles are too: NULL for a + // minute whose requests all failed before a latency was recorded. #[derive(Debug, clickhouse::Row, serde::Deserialize)] struct Row { bucket_ts: i64, - p50: f64, - p95: f64, + p50: Option, + p95: Option, requests: u64, errors: u64, } @@ -1348,8 +1350,8 @@ pub async fn get_route_history( .into_iter() .map(|r| RouteHistoryBucket { ts: r.bucket_ts, - p50_ms: if r.p50.is_finite() { Some(r.p50) } else { None }, - p95_ms: if r.p95.is_finite() { Some(r.p95) } else { None }, + p50_ms: r.p50.filter(|v| v.is_finite()), + p95_ms: r.p95.filter(|v| v.is_finite()), requests: r.requests, errors: r.errors, }) diff --git a/crates/server/src/handlers/trace.rs b/crates/server/src/handlers/trace.rs index efa5d711..0900869e 100644 --- a/crates/server/src/handlers/trace.rs +++ b/crates/server/src/handlers/trace.rs @@ -108,7 +108,7 @@ pub async fn get_trace( let rows: Vec = ch .query( "SELECT id, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ model_id, status_code, latency_ms, user_id \ FROM gateway_logs \ WHERE trace_id = ?", @@ -141,7 +141,7 @@ pub async fn get_trace( let rows: Vec = ch .query( "SELECT id, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ tool_name, status, duration_ms, user_id \ FROM mcp_logs \ WHERE trace_id = ?", @@ -172,7 +172,7 @@ pub async fn get_trace( let rows: Vec = ch .query( "SELECT id, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ action, user_id \ FROM audit_logs \ WHERE trace_id = ?", @@ -210,6 +210,10 @@ pub async fn get_trace( level: String, message: String, } + // `app_logs.created_at`, qualified: unqualified, the name means + // the String alias above, the comparison with a DateTime fails, + // and `unwrap_or_default` turned that into "no app events". + // // Escape LIKE metacharacters in the user-supplied trace_id so // `trace_id=%` doesn't trigger a 1h-window app_logs scan // bypassing the substring-search intent. LIMIT 200 + PREWHERE @@ -222,10 +226,10 @@ pub async fn get_trace( let app_rows: Vec = ch .query( "SELECT id, \ - formatDateTime(created_at, '%Y-%m-%dT%H:%M:%S.%fZ', 'UTC') AS created_at, \ + formatDateTime(created_at, '%Y-%m-%dT%H:%i:%S.%fZ', 'UTC') AS created_at, \ level, message \ FROM app_logs \ - PREWHERE created_at >= now() - INTERVAL 1 HOUR \ + PREWHERE app_logs.created_at >= now() - INTERVAL 1 HOUR \ AND (fields LIKE ? OR span LIKE ?) \ LIMIT 200", ) diff --git a/crates/test-support/src/ch.rs b/crates/test-support/src/ch.rs index 8f43457e..9adeae77 100644 --- a/crates/test-support/src/ch.rs +++ b/crates/test-support/src/ch.rs @@ -43,14 +43,18 @@ impl IsolatedClickHouseDatabase { .await .context("CREATE DATABASE on test ClickHouse")?; - // Test client targets the new DB. - let mut client = client_for(url).with_database(&name); - if let Some(u) = user { - client = client.with_user(u); - } - if let Some(p) = password { - client = client.with_password(p); - } + // The application's own client, pointed at the new DB: the same + // settings, row validation and connector as production, so a + // query that would fail there fails here. + let client = think_watch_common::clickhouse_client::create_client( + &think_watch_common::audit::AuditConfig { + clickhouse_url: Some(url.to_string()), + clickhouse_db: name.clone(), + clickhouse_user: user.map(String::from), + clickhouse_password: password.map(String::from), + }, + ) + .context("build the ClickHouse client")?; // Load the production schema into the per-test DB. The // bundled init SQL contains a `CREATE DATABASE IF NOT EXISTS diff --git a/crates/test-support/tests/clickhouse_row_types.rs b/crates/test-support/tests/clickhouse_row_types.rs new file mode 100644 index 00000000..b8848a94 --- /dev/null +++ b/crates/test-support/tests/clickhouse_row_types.rs @@ -0,0 +1,429 @@ +//! Every ClickHouse read the console makes, against rows with every +//! column filled in. +//! +//! The clickhouse crate validates each result against the Rust row it +//! lands in: column names and types must match exactly, `i64` against +//! `UInt64` included, and a mismatch fails the query. The check runs on +//! the first row and on each non-NULL value, so a query that returns +//! nothing, or only NULLs, proves nothing. This file seeds all five log +//! tables (and so the rollups their materialised views feed) with +//! complete rows, then calls each endpoint in each of its query shapes: +//! global and team-scoped, every range, `compare`, every `group_by` +//! dimension. Endpoints that hide a failed query behind an empty answer +//! are checked for data, not only for a 200. + +use serde_json::Value; +use think_watch_test_support::prelude::*; + +async fn get(con: &TestClient, path: &str) -> Value { + let resp = con.get(path).await.unwrap(); + resp.assert_ok(); + resp.json().unwrap() +} + +async fn login(app: &TestApp, user: &fixtures::SeededUser) -> TestClient { + let con = app.console_client(); + con.post( + "/api/auth/login", + json!({"email": user.user.email, "password": user.plaintext_password}), + ) + .await + .unwrap() + .assert_ok(); + con +} + +/// What the seeded rows point at, so the tests can ask for them. +struct Seeded { + member: fixtures::SeededUser, + manager: fixtures::SeededUser, + team: Uuid, + provider_name: String, + model_id: String, + route_id: Uuid, + role_id: Uuid, + trace_id: String, + gateway_log_id: String, + mcp_log_id: String, +} + +async fn seed(app: &TestApp) -> Seeded { + let db = &app.db; + let ch = app + .state + .clickhouse + .as_ref() + .expect("ClickHouse configured"); + + // Postgres: a team with one member, a manager scoped to that team, + // a key, a model route and an MCP server for the rows to name. + let team: Uuid = + sqlx::query_scalar("INSERT INTO teams (name, description) VALUES ($1, 'rt') RETURNING id") + .bind(unique_name("rowtypes-team")) + .fetch_one(db) + .await + .unwrap(); + let member = fixtures::create_random_user(db).await.unwrap(); + sqlx::query("INSERT INTO team_members (user_id, team_id) VALUES ($1, $2)") + .bind(member.user.id) + .bind(team) + .execute(db) + .await + .unwrap(); + let manager = fixtures::create_user(db, &unique_email(), "Manager", "MgrPwd_1234567!") + .await + .unwrap(); + sqlx::query( + r#"INSERT INTO rbac_role_assignments (user_id, role_id, scope_kind, scope_id, assigned_by) + SELECT $1, id, 'team', $2, $1 FROM rbac_roles WHERE name = 'team_manager'"#, + ) + .bind(manager.user.id) + .bind(team) + .execute(db) + .await + .unwrap(); + let key = fixtures::create_api_key( + db, + member.user.id, + &unique_name("rowtypes-key"), + &["ai_gateway"], + None, + None, + ) + .await + .unwrap(); + sqlx::query("UPDATE api_keys SET cost_center = 'eng' WHERE id = $1") + .bind(key.row.id) + .execute(db) + .await + .unwrap(); + + let provider_name = unique_name("rowtypes-provider"); + let provider = + fixtures::create_provider(db, &provider_name, "openai", "http://127.0.0.1:9", None) + .await + .unwrap(); + let model_id = unique_name("rowtypes-model"); + fixtures::create_model_and_route(db, provider.id, &model_id) + .await + .unwrap(); + let route_id: Uuid = + sqlx::query_scalar("SELECT id FROM model_routes WHERE model_id = $1 AND provider_id = $2") + .bind(&model_id) + .bind(provider.id) + .fetch_one(db) + .await + .unwrap(); + + let short = Uuid::new_v4().simple().to_string()[..12].to_string(); + let server_id = fixtures::create_mcp_server( + db, + &format!("rowtypes-mcp-{short}"), + &format!("rt_{short}"), + "http://127.0.0.1:9/mcp", + ) + .await + .unwrap(); + let role_id: Uuid = sqlx::query_scalar("SELECT id FROM rbac_roles WHERE name = 'team_manager'") + .fetch_one(db) + .await + .unwrap(); + + // ClickHouse: rows with no NULL anywhere. Recent rows feed the 15- + // minute and one-hour windows; older ones the 7- and 30-day buckets + // and the previous windows `compare` reads. + let trace_id = Uuid::new_v4().to_string(); + let gateway_log_id = Uuid::new_v4().to_string(); + let mcp_log_id = Uuid::new_v4().to_string(); + let user = member.user.id.to_string(); + let email = member.user.email.clone(); + let key_id = key.row.id.to_string(); + let lineage = key.row.lineage_id.to_string(); + + for (i, age) in ["2 MINUTE", "3 HOUR", "2 DAY", "9 DAY", "40 DAY"] + .into_iter() + .enumerate() + { + let id = if i == 0 { + gateway_log_id.clone() + } else { + Uuid::new_v4().to_string() + }; + ch.query(&format!( + "INSERT INTO gateway_logs (id, user_id, user_email, api_key_id, api_key_lineage_id, \ + model_id, provider, upstream_model, input_tokens, output_tokens, cost_usd, \ + latency_ms, status_code, ip_address, user_agent, detail, trace_id, session_id, \ + request_body, response_body, request_body_bytes, response_body_bytes, \ + body_capture_status, created_at) VALUES \ + ('{id}', '{user}', '{email}', '{key_id}', '{lineage}', '{model_id}', \ + '{provider_name}', '{model_id}', 120, 80, toDecimal64('0.0123', 10), 345, 200, \ + '10.0.0.1', 'rowtypes', '{{}}', '{trace_id}', 'sess', '{{\"q\":1}}', \ + '{{\"a\":1}}', 7, 7, 'captured', now64(3) - INTERVAL {age})" + )) + .execute() + .await + .unwrap(); + } + // An error and a throttled answer for the success / error rates. + for status in [500, 429] { + ch.query(&format!( + "INSERT INTO gateway_logs (id, user_id, user_email, api_key_id, api_key_lineage_id, \ + model_id, provider, upstream_model, input_tokens, output_tokens, cost_usd, \ + latency_ms, status_code, ip_address, user_agent, detail, trace_id, session_id, \ + request_body, response_body, request_body_bytes, response_body_bytes, \ + body_capture_status, created_at) VALUES \ + ('{}', '{user}', '{email}', '{key_id}', '{lineage}', '{model_id}', \ + '{provider_name}', '{model_id}', 1, 1, toDecimal64('0.0001', 10), 50, {status}, \ + '10.0.0.1', 'rowtypes', '{{}}', '{trace_id}', 'sess', '{{}}', '{{}}', 2, 2, \ + 'captured', now64(3) - INTERVAL 1 MINUTE)", + Uuid::new_v4() + )) + .execute() + .await + .unwrap(); + } + for (i, age) in ["2 MINUTE", "2 DAY", "40 DAY"].into_iter().enumerate() { + let id = if i == 0 { + mcp_log_id.clone() + } else { + Uuid::new_v4().to_string() + }; + ch.query(&format!( + "INSERT INTO mcp_logs (id, user_id, user_email, server_id, server_name, tool_name, \ + duration_ms, status, error_message, ip_address, detail, tool_arguments, \ + tool_result, arguments_bytes, result_bytes, body_capture_status, trace_id, \ + created_at) VALUES \ + ('{id}', '{user}', '{email}', '{server_id}', 'rowtypes-mcp', 'search', 42, 'ok', \ + 'none', '10.0.0.1', '{{}}', '{{\"q\":1}}', '{{\"r\":1}}', 7, 7, 'captured', \ + '{trace_id}', now64(3) - INTERVAL {age})" + )) + .execute() + .await + .unwrap(); + } + let role = role_id.to_string(); + for (resource, resource_id) in [ + ("role", role.as_str()), + ("rate_limit_rule", user.as_str()), + ("api_key", key_id.as_str()), + ] { + ch.query(&format!( + "INSERT INTO audit_logs (id, user_id, user_email, api_key_id, api_key_lineage_id, \ + action, resource, resource_id, detail, ip_address, user_agent, trace_id, \ + created_at) VALUES \ + ('{}', '{user}', '{email}', '{key_id}', '{lineage}', '{resource}.update', \ + '{resource}', '{resource_id}', '{{\"subject_id\":\"{user}\"}}', '10.0.0.1', \ + 'rowtypes', '{trace_id}', now64(3) - INTERVAL 2 MINUTE)", + Uuid::new_v4() + )) + .execute() + .await + .unwrap(); + } + ch.query(&format!( + "INSERT INTO app_logs (id, level, target, message, fields, span, created_at) VALUES \ + ('{}', 'INFO', 'rowtypes', 'seeded', '{{\"trace_id\":\"{trace_id}\"}}', \ + 'request{{trace_id={trace_id}}}', now64(3) - INTERVAL 2 MINUTE)", + Uuid::new_v4() + )) + .execute() + .await + .unwrap(); + ch.query(&format!( + "INSERT INTO access_logs (id, method, path, status_code, latency_ms, port, user_id, \ + user_email, ip_address, user_agent, created_at) VALUES \ + ('{}', 'GET', '/api/rowtypes', 200, 12, 3001, '{user}', '{email}', '10.0.0.1', \ + 'rowtypes', now64(3) - INTERVAL 2 MINUTE)", + Uuid::new_v4() + )) + .execute() + .await + .unwrap(); + + Seeded { + member, + manager, + team, + provider_name, + model_id, + route_id, + role_id, + trace_id, + gateway_log_id, + mcp_log_id, + } +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn analytics_and_dashboard_queries_read_their_rows() { + let app = TestApp::spawn_with_clickhouse().await; + let admin = admin_session(&app).await; + let s = seed(&app).await; + let mgr = login(&app, &s.manager).await; + let team = s.team; + + for con in [&admin, &mgr] { + for range in ["24h", "7d", "30d"] { + for q in [ + format!("/api/analytics/usage/stats?range={range}&compare=true"), + format!("/api/analytics/costs/stats?range={range}&compare=true"), + format!("/api/dashboard/stats?range={range}&compare=true"), + format!("/api/analytics/usage?range={range}"), + ] { + get(con, &q).await; + } + for group_by in [ + "model", + "user", + "cost_center", + "provider", + "model,provider,user", + ] { + let body = get( + con, + &format!("/api/analytics/costs?range={range}&group_by={group_by}"), + ) + .await; + assert!( + !body["items"].as_array().unwrap().is_empty(), + "{group_by} {range}: {body}" + ); + } + } + // The AI row and the MCP row both come from ClickHouse, and the + // AI one through the rollup only for the global view. + let live = get(con, "/api/dashboard/live").await; + let providers = live["providers"].as_array().unwrap(); + for name in [s.provider_name.as_str(), "rowtypes-mcp"] { + assert!( + providers + .iter() + .any(|p| p["provider"] == name && p["requests"].as_u64() > Some(0)), + "{name} health: {live}" + ); + } + assert!( + !live["recent_logs"].as_array().unwrap().is_empty(), + "recent logs: {live}" + ); + } + get( + &admin, + &format!("/api/analytics/costs/stats?range=7d&compare=true&team_id={team}"), + ) + .await; + get(&admin, "/api/analytics/cost-forecast").await; + get(&admin, "/api/admin/slo?hours=24").await; + let csv = admin.get("/api/admin/chargeback.csv").await.unwrap(); + csv.assert_ok(); + assert!(csv.text().contains("eng"), "{}", csv.text()); + + let license = get(&admin, "/api/admin/usage-license").await; + assert!(license.is_object(), "{license}"); + + let limits = get( + &admin, + &format!("/api/admin/users/{}/limits-dashboard", s.member.user.id), + ) + .await; + assert!( + !limits["usage_7d"].as_array().unwrap().is_empty(), + "usage series: {limits}" + ); + assert!( + !limits["recent_events"].as_array().unwrap().is_empty(), + "recent limit events: {limits}" + ); + + let history = get( + &admin, + &format!( + "/api/admin/models/{}/route-history?route_id={}&window=3600", + s.model_id, s.route_id + ), + ) + .await; + let buckets = history["buckets"].as_array().unwrap(); + assert!(!buckets.is_empty(), "route history: {history}"); + assert!( + buckets.iter().any(|b| b["p50_ms"].as_f64() == Some(345.0)), + "route history: {history}" + ); + + let servers = get(&admin, "/api/mcp/servers").await; + assert!( + servers + .as_array() + .unwrap() + .iter() + .any(|srv| srv["call_count"].as_i64() > Some(0)), + "MCP call counts: {servers}" + ); + + for path in ["/health/ready", "/api/health", "/api/admin/settings/audit"] { + admin.get(path).await.unwrap().assert_ok(); + } +} + +#[ignore = "integration test — run via `make test-it`"] +#[tokio::test] +async fn log_trace_and_history_queries_read_their_rows() { + let app = TestApp::spawn_with_clickhouse().await; + let admin = admin_session(&app).await; + let s = seed(&app).await; + + for path in [ + "/api/gateway/logs", + "/api/mcp/logs", + "/api/audit/logs", + "/api/admin/app-logs", + "/api/admin/access-logs", + ] { + let body = get(&admin, path).await; + let items = body["items"] + .as_array() + .or_else(|| body["data"].as_array()) + .or_else(|| body.as_array()) + .unwrap_or_else(|| panic!("{path}: {body}")); + assert!(!items.is_empty(), "{path}: {body}"); + } + + let body = get( + &admin, + &format!("/api/admin/gateway/logs/{}/body", s.gateway_log_id), + ) + .await; + let at = body["created_at"].as_str().unwrap_or_default(); + assert!(chrono::DateTime::parse_from_rfc3339(at).is_ok(), "{body}"); + let body = get( + &admin, + &format!("/api/admin/mcp/logs/{}/body", s.mcp_log_id), + ) + .await; + assert!(body.is_object(), "{body}"); + + let trace = get(&admin, &format!("/api/admin/trace/{}", s.trace_id)).await; + let events = trace["events"].as_array().unwrap(); + for kind in ["gateway", "mcp", "audit", "app"] { + assert!( + events.iter().any(|e| e["kind"] == kind), + "trace lacks {kind}: {trace}" + ); + } + // `%M` in ClickHouse's formatDateTime is the month name, not the + // minute; the timestamps have to be RFC 3339. + for e in events { + let at = e["created_at"].as_str().unwrap(); + assert!( + chrono::DateTime::parse_from_rfc3339(at).is_ok(), + "not RFC 3339: {at}" + ); + } + + let history = get(&admin, &format!("/api/admin/roles/{}/history", s.role_id)).await; + assert!( + !history["items"].as_array().unwrap().is_empty(), + "role history: {history}" + ); +} diff --git a/deploy/docker/Dockerfile.web b/deploy/docker/Dockerfile.web index 723747a5..08fef67d 100644 --- a/deploy/docker/Dockerfile.web +++ b/deploy/docker/Dockerfile.web @@ -8,11 +8,14 @@ # never exited — the CI job sat idle until GitHub's six-hour limit. FROM --platform=$BUILDPLATFORM node:24.21.0-alpine AS builder -# Pin pnpm to a known-good 11.x. The workspace YAML's `allowBuilds` -# field requires pnpm 11+ (see web/pnpm-workspace.yaml top comment). -# Pinning the build avoids `latest` flipping under us on a future -# image bake and producing a non-reproducible dist/. -RUN corepack enable && corepack prepare pnpm@11.28.4 --activate +# Pin pnpm to a known-good 12.x, matching `packageManager` in +# web/package.json. The workspace YAML's `allowBuilds` field requires +# pnpm 11+ (see web/pnpm-workspace.yaml top comment). Pinning the build +# avoids `latest` flipping under us on a future image bake and producing +# a non-reproducible dist/. pnpm 12 is a native binary: Corepack fetches +# the `pnpm` wrapper, whose first run downloads the musl build from the +# npm registry and checks it against npm's signature before running it. +RUN corepack enable && corepack prepare pnpm@12.9.1 --activate WORKDIR /app # pnpm-workspace.yaml carries the install-script allowlist (see its diff --git a/web/package.json b/web/package.json index 386c9466..81303754 100644 --- a/web/package.json +++ b/web/package.json @@ -3,7 +3,7 @@ "private": true, "version": "3.2.0", "type": "module", - "packageManager": "pnpm@11.28.4", + "packageManager": "pnpm@12.9.1", "scripts": { "dev": "vite", "build": "node scripts/check-i18n.mjs && tsc -b && vite build", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 95fe7a53..f19ea070 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.9.1 + version: 12.9.1 + +packages: + + '@pnpm/exe.android-arm64@12.9.1': + resolution: {integrity: sha512-Hgm1XdqMTfBC8PdGLL+NjZWv4xyi7D6XNrN9fb24XGYlyTBmUtdCefKnCLmHujShRD8BEO1zqM30SNcKBDLysw==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.9.1': + resolution: {integrity: sha512-WDGCe6jZRVZP3RC/q6hOkZylkPkIQIi1/GcrR/d/YzbihhAHXCczrk+UYnV5TgbsEnAYO7Cx+2rX3nfhHvy24w==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.9.1': + resolution: {integrity: sha512-+7iiEEBgYN9szPWUnAuN6eOVMTI6Hrki5bcuC9n9xl9tTnT53Tpxpo45Hml+W4IrAU0d+Tmd0sPnVIbX59PD6w==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.9.1': + resolution: {integrity: sha512-SK7TxJiR12GbJZGffRFHItkc+Mx+99v6xMMJ9/X//8NwWV1wo17a2bwfGI8qZpl17YwsrmPEFOOqhzE1GIlrtQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.9.1': + resolution: {integrity: sha512-4AjLk6uvTs2iO6wMit2hcxJ2M32xb7OV5SNFk+w6G8/NzcjcMB7ENEpyeXyLiP0kcnp8uaCGUct2NfrLCJ35eQ==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.9.1': + resolution: {integrity: sha512-dZL2ES1ckDWM9v+EtxoOklZ0WNdtayooi9iLiDUubopudX3zXPNR2mlrxBRgjbDgECOndQen2SmyxL5eZiBuKw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.9.1': + resolution: {integrity: sha512-cx+uF6xqwEtm7B8NV+1ve0xBg9gcuzQZ5R+0WgOWvKG/h978UljS6BgWs1ipIGuGgUdeVhm3PKmBltXIcczLXA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.9.1': + resolution: {integrity: sha512-MpjPsy4XR17zAtLziQZfsN+wveWa/bUJggZtwfo3+SuyRmXnSvVkFD4+v8/3RTWyYaM6mwLUTjZDJ+g3urL+ew==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.9.1': + resolution: {integrity: sha512-Oo67TKqqH+Bno+L6X7Xyorwp1QG82zKIkL0vbo1x2kcPTDiyPbdM+d6lIs4+hzeB1KHT5FDoGzZj2dad7Ft+eg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.9.1': + resolution: {integrity: sha512-QAD5FRCdW36HfD58BV7DoDm6z8yv+GgSqcUV1+ERwYjpVQuG6ZTpf5o0x3KDas8eTLIW7MLYVdDdlAurWeArEg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.9.1': + resolution: {integrity: sha512-mQDhBsGqcTkrLQM4q5igmRFcr6EAl4UM/FGawY+0CWGcqPpUmYUvDy9LHy80xxkHIWVJ886gvvso2XOjfav1Jw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.9.1': + resolution: {integrity: sha512-+M5N74hM4fc87AAckxx/UIW+dUnWf2eko+HchJs/NZUxehLhekNyMI/GNaNXlvd+kWR+97XXBD51XRkkNiUtrA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.9.1': + resolution: {integrity: sha512-mI28C2wxobrDi6x1OYhBRyG10hnDq/4sEIyy5RzoX6LB4kLuv6K/d25HOpMwlW20cuM4M72k9NZpOdcmUxMbEA==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.9.1': + resolution: {integrity: sha512-SWoDMT0oD7NrDSIoPpkKD09D9fAQKNjwFZ3lezlCUhAfXxMv70zsfrNT2/P+GoCK2kefXF9OysUZkH1l5vvvfA==} + cpu: [x64] + os: [win32] + + pnpm@12.9.1: + resolution: {integrity: sha512-BrBV//XNINwSeB3hc87TMQzglRvy7GICEaFgRdVETVyTpmMhB2TvKaZTphBFm6A2jw50+E2AxUcjz5Wq57wNbQ==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.9.1': + optional: true + + '@pnpm/exe.android-x64@12.9.1': + optional: true + + '@pnpm/exe.darwin-arm64@12.9.1': + optional: true + + '@pnpm/exe.darwin-x64@12.9.1': + optional: true + + '@pnpm/exe.freebsd-x64@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-arm64@12.9.1': + optional: true + + '@pnpm/exe.linux-ppc64@12.9.1': + optional: true + + '@pnpm/exe.linux-riscv64@12.9.1': + optional: true + + '@pnpm/exe.linux-s390x@12.9.1': + optional: true + + '@pnpm/exe.linux-x64-musl@12.9.1': + optional: true + + '@pnpm/exe.linux-x64@12.9.1': + optional: true + + '@pnpm/exe.win32-arm64@12.9.1': + optional: true + + '@pnpm/exe.win32-x64@12.9.1': + optional: true + + pnpm@12.9.1: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.9.1 + '@pnpm/exe.android-x64': 12.9.1 + '@pnpm/exe.darwin-arm64': 12.9.1 + '@pnpm/exe.darwin-x64': 12.9.1 + '@pnpm/exe.freebsd-x64': 12.9.1 + '@pnpm/exe.linux-arm64': 12.9.1 + '@pnpm/exe.linux-arm64-musl': 12.9.1 + '@pnpm/exe.linux-ppc64': 12.9.1 + '@pnpm/exe.linux-riscv64': 12.9.1 + '@pnpm/exe.linux-s390x': 12.9.1 + '@pnpm/exe.linux-x64': 12.9.1 + '@pnpm/exe.linux-x64-musl': 12.9.1 + '@pnpm/exe.win32-arm64': 12.9.1 + '@pnpm/exe.win32-x64': 12.9.1 + +--- lockfileVersion: '9.0' settings: