From 16d360eed6a80a9c68a0eae3ebf14ea64a64b67f Mon Sep 17 00:00:00 2001 From: Mike Nitsenko Date: Wed, 2 Sep 2026 14:11:19 +0500 Subject: [PATCH 1/7] feat(cube-cli): list dbt sync history and read one sync's logs (#11625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cube-cli): list dbt sync history and read one sync's logs `cube dbt` could start and follow a sync, but not look back at one. Two commands complete it: - `cube dbt history ` lists recent syncs — id, status, trigger, start, duration, branch — paged with `--first`/`--after`. - `cube dbt logs ` prints a sync's phase timeline and the text a failed phase produced, colouring failure entries. A duration is the server's own `durationMs` or an empty cell — never the difference of two stamps written by different processes, which can disagree with it and, for a run that fails moments after starting, be negative. The two `--wait` failure messages now name `cube dbt logs` for the run that failed, which is the difference between a CI step that explains itself and one that only says "dbt sync failed"; both paths build that message through one function instead of two copies. Co-Authored-By: Claude Opus 5 (1M context) * fix(cube-cli): address dbt history and logs review feedback - Every history cell is read the same way, through one `cell` reader: `status` keeps its single key, now with the reason it is the one field that cannot have a second spelling. - Cells are bounded and single-line, so a value carrying a newline can no longer break the row it sits in, nor an unbounded one the layout. - Server log text is stripped of control characters other than the line breaks and tabs the timeline keeps on purpose. `one_line` was never the guard it looks like: ESC is not whitespace, so a hostile dbt error could have retitled a window or overwritten the lines above it in a CI log. - The raw-entry fallback no longer repeats the timestamp and stage the JSON already carries, and keeps its red when the entry says it is a failure — the entry this build understood least is the last place to drop that signal. - `history`'s "could not read this" warning keys on the sync job id rather than on every cell being blank, which one filled column was enough to defeat. - `human_duration_ms` rejects a float too large to cast, which saturated into a confident five-billion-hour duration instead of passing through. Co-Authored-By: Claude Opus 5 (1M context) * feat(cube-cli): filter dbt history, and time each phase in dbt logs Aligns both commands with what the endpoints they call actually publish. - `dbt history` takes `--status` and `--trigger`, sent through unchecked: the two vocabularies are the server's, and a filter this build has not heard of is one the server can still honour, where a list hard-coded here would refuse it. - `dbt logs` drops `--first`/`--after`. One sync's timeline is one page, bounded by the number of phases it ran, so the flags were accepted here and ignored there — a promise of paging that does not exist. - A log line now carries how long its phase took, sharing one bracket with the phase name so a multi-line failure is interrupted by neither. The timings are half of what makes this a timeline rather than a list of remarks. - Fields are read under the names the endpoints publish, and only those: the second spellings were insurance taken out before the shapes were settled, and every one of them was dead. `status` is no longer the odd column out, since no column carries an alias now. A listed run can be CANCELLED or UNKNOWN as well as the two the status endpoint calls terminal. Nothing here acts on a status, so they pass through as they arrived; the docs note that a cancelled run is still a failure to a `--wait` gate, which needs a terminal answer. Co-Authored-By: Claude Opus 5 (1M context) * fix(cube-cli): refuse an empty dbt history filter, and spell its case out - `--status` and `--trigger` carry a `nonempty_filter` parser, like every other free-text argument in the tree. An empty value is not dropped — `push` sends `status=` — so a CI script whose `$STATUS` did not expand would have listed whatever the server made of an empty filter. - Both vocabularies are the server's and they do not share a case (statuses upper, triggers lower), so the help and the docs now spell that out: a mis-cased value is the one mistake that may come back as an empty table rather than as a complaint, and an empty table reads as an answer. Co-Authored-By: Claude Opus 5 (1M context) * fix(cube-cli): trim a dbt history filter, unlike a branch name A filter is one word out of a vocabulary the server publishes, and no member of it has a space in it — so ` FAILED` could only ever match nothing, landing in the exact failure this argument's help was written to prevent: an empty table that reads as an answer. `$(jq -r …)` and a value read out of a file are the ordinary ways to acquire the padding. The two helpers beside it still return what they were given, because a branch name is the caller's own and `--branch ' x '` can name a branch that exists. A test pins the divergence rather than leaving it to be read as an oversight. Also rewraps the docs paragraph the previous commit left one line too long. Co-Authored-By: Claude Opus 5 (1M context) * test(cube-cli): drop a field the run record does not carry The fixture claimed an `updatedAt` on a listed run. The endpoint deliberately does not publish one — the column behind it is frozen at the launch insert, so a field with that name would never update — and a fixture that carries what the transport does not is the kind of self-consistent wrong stub that green-lights a reader nobody has actually exercised. Nothing read it, so this is fidelity only. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- docs-mintlify/reference/cli.mdx | 48 ++- rust/cube-cli/src/commands/dbt.rs | 617 ++++++++++++++++++++++++++++-- rust/cube-cli/src/util.rs | 44 +++ 3 files changed, 678 insertions(+), 31 deletions(-) diff --git a/docs-mintlify/reference/cli.mdx b/docs-mintlify/reference/cli.mdx index 058c157858d9e..86435a4f06824 100644 --- a/docs-mintlify/reference/cli.mdx +++ b/docs-mintlify/reference/cli.mdx @@ -177,7 +177,7 @@ Run `cube --help` for the full options of any command. | `regions` | List available deployment regions | | `github` (`gh`) | GitHub integration: `status`, `installations`, `repos`, `branches`, `connect` | | `data-model` | Data model files and Git workflow: `list`, `get`, `put`, `delete`, `rename`, `file-hashes`, `branches`, `create-branch`, `delete-branch`, `enable-branch`/`disable-branch`, `dev-mode`, `commit`, `pull`, `merge`, `merge-to-default` | -| `dbt` | dbt sync: `sync` (`--ref`, `--wait`), `status`, `result`, `cancel` | +| `dbt` | dbt sync: `sync` (`--ref`, `--wait`), `status`, `result`, `logs`, `history` (`--status`, `--trigger`), `cancel` | | `environments` | Deployment environments and environment tokens | | `variables` | Deployment environment variables | | `folders`, `workbooks`, `reports`, `workspace` | Workspace content management | @@ -368,6 +368,52 @@ commit. +### Sync history and logs + +`history` lists a deployment's recent syncs — how each one was triggered, how it +ended, and how long it took — and `logs` prints one sync's phase timeline, +including the text a failed phase produced: + +```bash +cube dbt history DEPLOYMENT_ID +cube dbt logs DEPLOYMENT_ID SYNC_JOB_ID +``` + +`history` narrows with `--status` (`RUNNING`, `COMPLETED`, `FAILED`, `CANCELLED`, +`UNKNOWN`) and `--trigger` (`manual`, `api`, `webhook`, `agent`, `unknown`) — both +case-sensitive as spelled here — and pages with `--first`/`--after`, taking the cursor +from `pageInfo.endCursor` in `--json` output. A page holds at most 100 runs, so a +larger `--first` returns 100 with `pageInfo.hasNextPage` set. `logs` takes no paging +flags — one sync's timeline is one page, and each line carries the phase it belongs +to and how long that phase took. + +Both need only `SchemaRead`. Durations are the server's own single-clock figure, so +they never disagree with the run they describe. `--json` carries the rest of each +record — the dbt ref that was synced, the phase that failed, per-phase timings and +manifest counts. + +`logs` is what turns a red CI step into something self-explaining: a failed +`--wait` reports the reason, and the timeline says which phase produced it. + +```bash +cube dbt sync "$DEPLOYMENT_ID" --ref "$GITHUB_HEAD_REF" --wait --json > sync.json || { + SYNC_JOB_ID=$(jq -r '.syncJobId // empty' sync.json) + [ -n "$SYNC_JOB_ID" ] && cube dbt logs "$DEPLOYMENT_ID" "$SYNC_JOB_ID" + exit 1 +} +``` + +A failed `--wait --json` still writes its document before exiting non-zero, which +is what leaves the `syncJobId` there to follow up on. + + + +A cancelled sync is listed as `CANCELLED` by `history`, but reported as a failure by +`status` and by `sync --wait` — a gate polling for a terminal answer needs one, and the +reason it prints says the sync was cancelled. + + + ### dbt sync as a CI test gate Sync the branch under review, compile it, query it, and fail the job if any step diff --git a/rust/cube-cli/src/commands/dbt.rs b/rust/cube-cli/src/commands/dbt.rs index 85487bd2f76d1..5b697736c44c2 100644 --- a/rust/cube-cli/src/commands/dbt.rs +++ b/rust/cube-cli/src/commands/dbt.rs @@ -2,6 +2,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Result}; use clap::Subcommand; +use owo_colors::OwoColorize; use serde_json::Value; use crate::client::{Client, Query}; @@ -64,6 +65,36 @@ enum Cmd { /// Sync job id, as returned by `sync` sync_job_id: String, }, + /// Show a dbt sync's phase timeline, including the text a failed phase produced + /// + /// No paging flags: one sync's timeline is one page, bounded by the number of + /// phases it ran, so flags would be accepted here and ignored by the server. + Logs { + /// Deployment id + deployment: i64, + /// Sync job id, as returned by `sync` + sync_job_id: String, + }, + /// List a deployment's recent dbt syncs + #[command(aliases = ["list", "ls"])] + History { + /// Deployment id + deployment: i64, + /// Only runs with this status, case-sensitive: RUNNING, COMPLETED, FAILED, + /// CANCELLED, UNKNOWN + #[arg(long, value_parser = util::nonempty_filter)] + status: Option, + /// Only runs started this way, case-sensitive: manual, api, webhook, agent, + /// unknown + #[arg(long, value_parser = util::nonempty_filter)] + trigger: Option, + /// Page size for cursor-based pagination (at most 100 per page) + #[arg(long)] + first: Option, + /// Cursor for the next page (from a previous pageInfo.endCursor) + #[arg(long)] + after: Option, + }, /// Cancel a running dbt sync Cancel { /// Deployment id @@ -195,6 +226,245 @@ fn print_prune_hint(sync_created_it: bool, deployment: i64, branch_name: &str) { } } +/// The error a terminal FAILED leaves behind, from both wait paths through one function +/// so the two cannot drift. +/// +/// Two sentences, because the reason alone is not the whole answer. The first is the +/// workflow's own words, which is what a human reading a failed job wants — collapsed +/// like the build failure in `deployments`, since a dbt reason is a compile or warehouse +/// error that arrives multi-line and `main` renders an anyhow chain on one line with +/// `{err:#}`; and `is_blank` rather than `is_empty`, because a reason of blanks would +/// fill the slot without answering it on the one line somebody reads when the gate goes +/// red. The second says WHICH PHASE produced it, which is the difference between a red +/// CI step that explains itself and one that only says "dbt sync failed" — and it earns +/// its place most in exactly the case the first sentence cannot fill. +/// +/// In the message rather than printed beside it, so it survives `--json` (where advice +/// has no place in the document, but stderr still carries it into the job log) and lands +/// after the reason rather than above it. The only signal a gate itself needs is still +/// the non-zero exit. +fn failure(deployment: i64, sync_job_id: &str, status: &Value) -> anyhow::Error { + let error = util::one_line(&output::field(status, "error"), util::REASON_LIMIT); + let reason = if util::is_blank(&error) { + "(no reason reported)" + } else { + &error + }; + + anyhow::anyhow!( + "dbt sync {sync_job_id} failed: {reason}. See which phase failed with \ + `cube dbt logs {deployment} {}`", + util::shell_quote(sync_job_id) + ) +} + +/// Server text as a terminal may safely show it: every control character except the +/// line breaks and tabs the timeline keeps on purpose is dropped. +/// +/// This is text the CLI did not write — dbt compile output, warehouse messages, model +/// names — and an ESC sequence in it can retitle a window, move the cursor, or overwrite +/// the lines above it in a CI log. `one_line` is not the guard it looks like: it drops +/// control characters that are WHITESPACE as a side effect of splitting on it, and ESC +/// is not whitespace. Printing raw would be safe only under `--json`, where `serde_json` +/// escapes them. +fn printable(text: &str) -> String { + text.chars() + .filter(|c| !c.is_control() || *c == '\n' || *c == '\t') + .collect() +} + +/// How much of one server-supplied value a table cell or a line prefix keeps. Long +/// enough for a branch name, a timestamp or a trigger with room to spare, short enough +/// that one row stays one row: a table is laid out to its widest cell, so an unbounded +/// one would push every other column off the screen. +const CELL_LIMIT: usize = 120; + +/// One bounded, printable line of server text — what a table cell and a timeline +/// prefix both need, and where trimming comes from: `one_line` splits on whitespace, so +/// padding and interior newlines go the same way. +fn one_cell(text: &str) -> String { + util::one_line(&printable(text), CELL_LIMIT) +} + +/// A `durationMs` rendered as time, because a sync runs for minutes and `912345` is +/// not a thing anyone reads off a table. +/// +/// Anything that is not a plain count of milliseconds is passed through untouched: +/// blank stays blank, and a value this build cannot parse is shown as it arrived +/// rather than turned into a confident `0s`. +fn human_duration_ms(raw: &str) -> String { + let raw = raw.trim(); + let ms = match raw.parse::() { + Ok(ms) => ms, + // A whole number of milliseconds that arrived as a float (`912345.0`) is still + // a duration; a negative or non-numeric one is not, and falls through. + // + // Bounded, not merely non-negative: an `as` cast SATURATES, so `1e30` would + // otherwise render as a confident five-billion-hour duration instead of passing + // through as the nonsense it is. + Err(_) => match raw.parse::() { + Ok(value) if value.is_finite() && value >= 0.0 && value < u64::MAX as f64 => { + value.round() as u64 + } + _ => return raw.to_string(), + }, + }; + + let seconds = ms / 1000; + let (minutes, seconds) = (seconds / 60, seconds % 60); + let (hours, minutes) = (minutes / 60, minutes % 60); + match (hours, minutes) { + (0, 0) if ms < 1000 => format!("{ms}ms"), + (0, 0) => format!("{seconds}s"), + (0, _) => format!("{minutes}m {seconds}s"), + _ => format!("{hours}h {minutes}m"), + } +} + +/// The columns of `history`, paired with the row `history_row` builds — the two are +/// positional, so they are declared next to each other and a test holds them the same +/// width. +const HISTORY_COLUMNS: [&str; 6] = [ + "SYNC JOB ID", + "STATUS", + "TRIGGER", + "STARTED", + "DURATION", + "BRANCH", +]; + +/// The column a row has to fill to be usable at all: without an id, nothing in it can +/// be passed to `logs` or `result`. +const ID_COLUMN: usize = 0; + +/// One run as a table row, under the names the list endpoint publishes. +/// +/// The columns are the six a run is identified and judged by; the rest of the record — +/// `gitRef`, `failedPhase`, `lastStage`, per-phase timings, manifest counts — is in +/// `--json`, which is where a table would stop being one. +fn history_row(run: &Value) -> Vec { + // Every cell through `one_cell`: these are server strings landing in a laid-out + // table, where an interior newline breaks the row and an unbounded value pushes the + // other columns off the screen. Padding goes with them, so a `COMPLETED ` cannot sit + // beside a `COMPLETED` and read as two outcomes. + let cell = |field: &str| one_cell(&output::field(run, field)); + + vec![ + cell("syncJobId"), + // Five values here, not the two the status endpoint calls terminal: a listed run + // can also be CANCELLED or UNKNOWN, and nothing in this command acts on them — + // it shows what the row says. + cell("status"), + cell("trigger"), + cell("startedAt"), + // `durationMs` ONLY — never `completedAt` minus `startedAt`. Those two stamps + // are written by different processes, so their difference can disagree with the + // server's own figure and, for a run that fails moments after starting, be + // negative. A run that reports no `durationMs` gets an empty cell, which is the + // honest answer; a computed one would be a plausible wrong number. + human_duration_ms(&cell("durationMs")), + cell("branchName"), + ] +} + +/// One line of a sync's timeline, read out of whichever fields the entry carried. +/// +/// Reading is separated from printing so it can be tested: colour is applied at the +/// call site, where the terminal is, and asserting on ANSI escapes would test +/// owo-colors rather than this. +struct LogEntry { + /// When it happened; blank when the entry did not say. + time: String, + /// The phase it belongs to. Blank stays blank rather than becoming empty brackets, + /// for the same reason `status_label` does not print them either. + phase: String, + /// How long that phase took, on the lines that measure one — the timings are half + /// of what makes this a timeline rather than a list of remarks. + duration: String, + message: String, + /// Whether this entry is a failure, so the line can be red. A level this build + /// does not recognise leaves it plain: colouring an unknown level red would + /// announce a failure the server never reported. + error: bool, +} + +fn log_entry(value: &Value) -> LogEntry { + LogEntry { + time: one_cell(&output::field(value, "timestamp")), + phase: one_cell(&output::field(value, "phase")), + duration: human_duration_ms(&one_cell(&output::field(value, "durationMs"))), + // Kept whole, unlike the prefix beside it and the poll label's `one_line`: this + // is the failure text itself, printed once, and a dbt compile error means its + // line breaks. Collapsing them would apply the label's rule where it does harm — + // so the control characters `one_line` would have taken with the newlines are + // dropped deliberately instead. + message: printable(output::field(value, "message").trim_end()), + // `error` is the level the endpoint documents beside `info`. The other two cost + // nothing and lean the safe way: colour is not a decision anything acts on, so a + // level this build has not met yet is better red than silently ordinary. + error: matches!( + output::field(value, "level") + .trim() + .to_ascii_uppercase() + .as_str(), + "ERROR" | "FATAL" | "CRITICAL" + ), + } +} + +/// A failure's text is red wherever it lands, the raw-entry fallback below included: +/// the entry this build understood least is the last place to drop the signal that it +/// is a failure. +fn paint_failure(text: String, error: bool) -> String { + if error { + text.red().to_string() + } else { + text + } +} + +/// One rendered line of the timeline. +/// +/// The colour lives here rather than at the call site, next to the choice of what to +/// show: the fallback below has to drop a prefix as well as swap the text, and those +/// are one decision rather than two. +fn log_line(value: &Value) -> String { + let LogEntry { + time, + phase, + duration, + message, + error, + } = log_entry(value); + + // The text is why somebody ran this command, so an entry this build cannot find it + // in is shown as it arrived rather than dropped — and on its own: the raw JSON + // already carries the timestamp and the phase that would otherwise prefix it, and + // `serde_json` escapes the control characters `printable` exists to drop. + if util::is_blank(&message) { + return paint_failure(value.to_string(), error); + } + + let mut parts = Vec::new(); + if !util::is_blank(&time) { + parts.push(time.dimmed().to_string()); + } + // The phase and its timing share one bracket — metadata on one side, the line's own + // text on the other, so a multi-line failure is not interrupted by either. Whichever + // of the two is missing is simply absent: no empty brackets, for the reason + // `status_label` prints none, and no bare parenthesis where a timing would go. + let labels: Vec = [phase, duration] + .into_iter() + .filter(|label| !util::is_blank(label)) + .collect(); + if !labels.is_empty() { + parts.push(format!("[{}]", labels.join(" ")).cyan().to_string()); + } + parts.push(paint_failure(message, error)); + + parts.join(" ") +} + /// A result is available only when it is a non-empty object. Some deployments return /// `200 null` or `{}` while the completed workflow is still publishing its result. fn available_result(result: Option) -> Option { @@ -316,23 +586,7 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { output::print_json(&wait_json(&started, &status, &branch_name, None)); } - // The only signal a CI gate needs is the non-zero exit. The message - // carries the workflow's own reason, which is what a human reading - // the failed job actually wants — and `is_blank` rather than `is_empty` - // because a reason of blanks would fill the slot without answering it, - // on the one line somebody reads when the gate goes red. - // Collapsed like the build failure in `deployments`: a dbt reason is a - // compile or warehouse error that arrives multi-line, and this is a - // `bail!` whose chain `main` renders on one line with `{err:#}`. - let error = util::one_line(&output::field(&status, "error"), util::REASON_LIMIT); - bail!( - "dbt sync {sync_job_id} failed: {}", - if util::is_blank(&error) { - "(no reason reported)".to_string() - } else { - error - } - ); + return Err(failure(deployment, &sync_job_id, &status)); } // COMPLETED from here on, so the result is a value rather than a @@ -424,19 +678,7 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { let status = wait_for_sync(&api, deployment, &sync_job_id, timeout, poll).await?; print_status(ctx.json, &status); if util::status_of(&status, "status") == FAILED { - // Collapsed like the build failure in `deployments`: a dbt reason is a - // compile or warehouse error that arrives multi-line, and this is a - // `bail!` whose chain `main` renders on one line with `{err:#}`. - let error = - util::one_line(&output::field(&status, "error"), util::REASON_LIMIT); - bail!( - "dbt sync {sync_job_id} failed: {}", - if util::is_blank(&error) { - "(no reason reported)".to_string() - } else { - error - } - ); + return Err(failure(deployment, &sync_job_id, &status)); } return Ok(()); @@ -467,6 +709,96 @@ pub async fn command(args: Args, ctx: &Ctx) -> Result<()> { ), } } + Cmd::Logs { + deployment, + sync_job_id, + } => { + let path = format!("{}/{sync_job_id}/logs", base(deployment)); + // 404 is the tenant answering rather than a transport failure, and the three + // things it can mean are all actionable — so say them, the way `status` does, + // instead of leaving a bare status line to be interpreted. + let Some(res) = api.get_optional(&path, &Vec::new()).await? else { + bail!( + "no logs for dbt sync {sync_job_id} on deployment {deployment}. It may \ + belong to another deployment, have aged out, or this tenant may not \ + serve the dbt sync history endpoints yet" + ); + }; + + if ctx.json { + output::print_json(&res); + + return Ok(()); + } + + let entries = output::items(&res); + if entries.is_empty() { + // Not a failure: a sync that has only just started has no timeline yet. + eprintln!("{}", "No log entries".dimmed()); + + return Ok(()); + } + + for entry in entries { + println!("{}", log_line(&entry)); + } + } + Cmd::History { + deployment, + status, + trigger, + first, + after, + } => { + let mut query = Vec::new(); + // Sent as given, unchecked against a list: the two vocabularies are the + // server's, and a filter this build has not heard of is one the server can + // still honour, where a list hard-coded here would refuse a run this tenant + // has. Only the empty string is refused, and at parse time — `push` does not + // drop it, it sends `status=`, and what that selects is not ours to guess. + // + // The case is the server's as well, and the two vocabularies do not share it + // — statuses upper, triggers lower. Hence the help spelling both out and + // saying so: a mis-cased value is the one mistake that may come back as an + // empty table rather than as a complaint, and an empty table reads as an + // answer. + util::push(&mut query, "status", &status); + util::push(&mut query, "trigger", &trigger); + util::push(&mut query, "first", &first); + util::push(&mut query, "after", &after); + let Some(res) = api.get_optional(&base(deployment), &query).await? else { + bail!( + "no dbt sync history for deployment {deployment}. The deployment may \ + not exist or may not be visible to this credential, or this tenant \ + may not serve the dbt sync history endpoints yet" + ); + }; + + if ctx.json { + output::print_json(&res); + + return Ok(()); + } + + let rows: Vec> = output::items(&res).iter().map(history_row).collect(); + // A page whose rows name no sync at all is one this build could not read: + // every run has an id, and a row without one cannot be passed on to `logs` or + // `result` either. Printed as blanks it would read as "these syncs are empty" + // rather than "this CLI did not understand them", so say so and point at + // `--json`, which answers whatever the columns cannot name. + // + // Keyed on the id rather than on every cell being blank, which a single + // filled column was enough to defeat. It still does not claim to catch ONE + // renamed field: a column of blanks beside filled ones is visible on its own, + // and a warning per column would fire on every legitimately empty one. + if !rows.is_empty() && rows.iter().all(|row| util::is_blank(&row[ID_COLUMN])) { + eprintln!( + "warning: these sync rows name no sync job id — re-run with --json, \ + or update the CLI with `cube update`" + ); + } + output::table(&HISTORY_COLUMNS, rows); + } Cmd::Cancel { deployment, sync_job_id, @@ -490,6 +822,231 @@ mod tests { use super::*; use serde_json::json; + /// The cell a named column holds, so a test names the column rather than an index + /// that a reordering would quietly point somewhere else. + fn cell(row: &[String], column: &str) -> String { + let index = HISTORY_COLUMNS + .iter() + .position(|header| *header == column) + .unwrap_or_else(|| panic!("no {column} column")); + + row[index].clone() + } + + #[test] + fn a_history_row_fills_every_column() { + // Positional, so a column added to one and not the other would shift every cell + // after it into the wrong header. + let row = history_row(&json!({})); + assert_eq!(row.len(), HISTORY_COLUMNS.len()); + assert!(row.iter().all(|value| value.is_empty())); + // And the column `history`'s warning reads is the one it means. + assert_eq!(HISTORY_COLUMNS[ID_COLUMN], "SYNC JOB ID"); + } + + #[test] + fn a_run_renders_the_record_the_list_endpoint_publishes() { + let run = json!({ + "syncJobId": "abc", "deploymentId": 42, "status": "COMPLETED", "trigger": "api", + "branchName": "dbt-sync/main-1", "gitRef": "feature/orders", + "startedAt": "2026-08-24T10:00:00Z", "completedAt": "2026-08-24T10:15:12Z", + "durationMs": 912_345, "stats": { "cubeCount": 12 } + }); + assert_eq!( + history_row(&run), + vec![ + "abc", + "COMPLETED", + "api", + "2026-08-24T10:00:00Z", + "15m 12s", + "dbt-sync/main-1" + ] + ); + // A run still in flight reports no duration and no branch of its own yet; the + // cells it cannot fill stay empty rather than being derived from its stamps. + let running = json!({ + "syncJobId": "abc", "status": "RUNNING", "trigger": "webhook", + "startedAt": "2026-08-24T10:00:00Z", "durationMs": null, "completedAt": null + }); + assert_eq!(cell(&history_row(&running), "DURATION"), ""); + assert_eq!(cell(&history_row(&running), "STATUS"), "RUNNING"); + // The two values a listed run can carry that the status endpoint never reports: + // nothing here acts on a status, so they pass through as they arrived. + for status in ["CANCELLED", "UNKNOWN"] { + let row = history_row(&json!({"syncJobId": "abc", "status": status})); + assert_eq!(cell(&row, "STATUS"), status); + } + // And a padded status names the state it reports, like everywhere else here. + assert_eq!( + cell(&history_row(&json!({"status": " FAILED\n"})), "STATUS"), + "FAILED" + ); + } + + #[test] + fn a_duration_is_the_servers_own_figure_or_nothing() { + // The trap this column exists to avoid: both stamps present, no `durationMs`. + // They are written by different processes, so their difference can disagree with + // the server's figure and — for a run that fails moments after starting — be + // negative. An empty cell is the honest answer; a computed one would be a + // plausible wrong number. + let stamps_only = json!({ + "syncJobId": "abc", + "startedAt": "2026-08-24T10:00:00Z", + "completedAt": "2026-08-24T10:05:00Z" + }); + assert_eq!(cell(&history_row(&stamps_only), "DURATION"), ""); + } + + #[test] + fn a_duration_reads_as_time() { + // A sync runs for minutes, so the unit has to survive being read off a table. + assert_eq!(human_duration_ms("999"), "999ms"); + assert_eq!(human_duration_ms("1000"), "1s"); + assert_eq!(human_duration_ms("59999"), "59s"); + assert_eq!(human_duration_ms("60000"), "1m 0s"); + assert_eq!(human_duration_ms("912345"), "15m 12s"); + assert_eq!(human_duration_ms("3600000"), "1h 0m"); + assert_eq!(human_duration_ms("5430000"), "1h 30m"); + // Serialised as a float, which is still a duration. + assert_eq!(human_duration_ms("912345.0"), "15m 12s"); + // Not a count of milliseconds: shown as it arrived rather than as a confident + // `0s`, which would report a run that took a quarter of an hour as instant. + assert_eq!(human_duration_ms(""), ""); + assert_eq!(human_duration_ms(" "), ""); + assert_eq!(human_duration_ms("-1"), "-1"); + assert_eq!(human_duration_ms("PT15M"), "PT15M"); + // An `as` cast saturates, so this has to be rejected before it becomes a + // confident five-billion-hour duration. + assert_eq!(human_duration_ms("1e30"), "1e30"); + assert_eq!(human_duration_ms("inf"), "inf"); + } + + #[test] + fn a_failure_names_the_reason_and_where_the_phase_is() { + let message = failure( + 42, + "sync-1", + &json!({"error": "Compilation Error in model fct_orders\n depends on 'stg_orders'"}), + ) + .to_string(); + // One line, because `main` renders the chain with `{err:#}`. + assert_eq!( + message, + "dbt sync sync-1 failed: Compilation Error in model fct_orders depends on \ + 'stg_orders'. See which phase failed with `cube dbt logs 42 'sync-1'`" + ); + // A reason of blanks fills the slot without answering it, so it is not a reason — + // and this is the case where the second sentence is the only answer there is. + for status in [json!({}), json!({"error": " "})] { + let message = failure(42, "sync-1", &status).to_string(); + assert!(message.contains("(no reason reported)"), "{message}"); + assert!(message.contains("cube dbt logs 42 'sync-1'"), "{message}"); + } + // Quoted, like every other suggested command here: an id is opaque in practice, + // but these are copied out of CI logs without being reread. + assert!(failure(42, "a;rm -rf b", &json!({})) + .to_string() + .contains("`cube dbt logs 42 'a;rm -rf b'`")); + } + + #[test] + fn a_log_entry_says_only_what_it_carried() { + let entry = log_entry(&json!({ + "timestamp": "2026-08-24T10:00:01Z", + "phase": "dbt-compile", + "level": "info", + "stream": "system", + "message": "Parsing dbt project\n" + })); + assert_eq!(entry.time, "2026-08-24T10:00:01Z"); + assert_eq!(entry.phase, "dbt-compile"); + // A line that measures no phase carries no timing, rather than a `0ms` it would + // read as having measured one. + assert_eq!(entry.duration, ""); + // Trailing whitespace only: this is the failure text itself, printed once, and a + // dbt compile error means its line breaks — unlike a poll label, which repeats. + assert_eq!(entry.message, "Parsing dbt project"); + assert!(!entry.error); + // A null phase — the endpoint's shape for a line that belongs to none — stays + // blank, so the line cannot render as empty brackets. + assert!(log_entry(&json!({"phase": null})).phase.is_empty()); + assert!(log_entry(&json!({"phase": " "})).phase.is_empty()); + // Failure levels colour the line; anything else is left plain rather than + // announcing a failure the server never reported. + for level in ["error", "ERROR", " Fatal ", "critical"] { + assert!(log_entry(&json!({"level": level})).error, "{level}"); + } + for level in ["warn", "WARNING", "info", "", " "] { + assert!(!log_entry(&json!({"level": level})).error, "{level}"); + } + } + + #[test] + fn a_log_line_carries_the_phase_and_its_timing() { + let line = log_line(&json!({ + "timestamp": "2026-08-24T10:00:01Z", + "level": "info", + "phase": "dbt-compile", + "stream": "system", + "message": "dbt compile finished", + "durationMs": 1_200 + })); + assert!(line.contains("2026-08-24T10:00:01Z"), "{line}"); + // One bracket for both, so a multi-line failure below is interrupted by neither. + assert!(line.contains("[dbt-compile 1s]"), "{line}"); + // Uncoloured, so the text arrives verbatim rather than wrapped. + assert!(line.ends_with("dbt compile finished"), "{line}"); + // Either half alone still reads, and neither absent leaves a hole. + assert!(log_line(&json!({"phase": "dbt-deps", "message": "x"})).contains("[dbt-deps]")); + assert!(log_line(&json!({"durationMs": 340, "message": "x"})).contains("[340ms]")); + let bare = log_line(&json!({"phase": " ", "durationMs": null, "message": "x"})); + assert!(!bare.contains('['), "no empty brackets: {bare}"); + assert!(bare.ends_with('x'), "{bare}"); + } + + #[test] + fn an_entry_whose_text_this_build_cannot_find_is_shown_as_it_arrived() { + // Exactly the entry, once: the JSON already carries whatever a prefix would + // repeat, so it is printed alone rather than after a timestamp and a phase. + let unknown = json!({"ts": "2026-08-24T10:00:01Z", "detail": "a newer shape"}); + assert_eq!(log_line(&unknown), unknown.to_string()); + // A failure keeps its colour even here — the entry this build understood least is + // the last place to drop the signal that it is one. + let failed = json!({"level": "error", "detail": "no message field"}); + let line = log_line(&failed); + assert!(line.contains(&failed.to_string()), "{line}"); + assert_ne!(line, failed.to_string(), "still coloured: {line}"); + } + + #[test] + fn server_text_cannot_drive_the_terminal() { + // dbt output and warehouse errors are text this CLI did not write, and an ESC + // sequence in one can retitle a window, move the cursor, or overwrite the lines + // above it in a CI log. The line breaks a compile error means are kept; the rest + // of the control characters are not. + let entry = log_entry(&json!({ + "phase": "dbt-compile\u{1b}[2J", + "message": "Compilation Error\n\u{1b}]0;retitled\u{7} in model fct_orders\tx" + })); + assert_eq!(entry.phase, "dbt-compile[2J"); + assert_eq!( + entry.message, + "Compilation Error\n]0;retitled in model fct_orders\tx" + ); + // The same for a cell, which is one line as well: a newline in a value would + // otherwise break the row it sits in, and an unbounded one the whole layout. + let row = history_row(&json!({ + "branchName": "dbt-sync/a\u{1b}[2J\nb", + "trigger": "T".repeat(500) + })); + assert_eq!(cell(&row, "BRANCH"), "dbt-sync/a[2J b"); + let trigger = cell(&row, "TRIGGER"); + assert!(trigger.ends_with('…'), "bounded: {trigger}"); + assert_eq!(trigger.chars().count(), CELL_LIMIT + 1); + } + #[test] fn only_nonempty_objects_are_results() { assert!(available_result(None).is_none()); diff --git a/rust/cube-cli/src/util.rs b/rust/cube-cli/src/util.rs index 4dc6cde86801d..b751c205aa683 100644 --- a/rust/cube-cli/src/util.rs +++ b/rust/cube-cli/src/util.rs @@ -178,6 +178,35 @@ pub fn nonempty_ref(s: &str) -> Result { Ok(s.to_string()) } +/// `nonempty` with a message specific to a LIST FILTER — `dbt history --status`, and +/// anything that follows it — where an empty value is neither a filter nor nothing at +/// all: `push` sends `status=`, and every runs / no runs / a complaint are three answers +/// a server could reasonably give it. +/// +/// The reachable case is the same one the two above were written for: a CI script whose +/// `$STATUS` did not expand, where the run this refuses would otherwise have listed +/// whatever the server made of an empty field. +/// +/// Padding is TRIMMED here, where the two above deliberately keep it — see +/// `branch_or_placeholder` for why they must. The difference is what the value is: a +/// branch name is the caller's own, `--branch ' x '` can name a branch that really +/// exists, and only the caller knows. A filter is one word out of a vocabulary the +/// server publishes, and no member of it has a space in it — so padding cannot be +/// meaningful, and passing it on lands in the very failure this argument's help text +/// was written to prevent: an empty table that reads as an answer rather than as a +/// value nothing could match. `$(jq -r …)` and a value read out of a file are the +/// ordinary ways to acquire it. +pub fn nonempty_filter(s: &str) -> Result { + if s.trim().is_empty() { + return Err(format!( + "{EMPTY_VALUE_REFUSED} selects nothing — and it is not dropped, but sent as an \ + empty filter, leaving what that matches to the server rather than to you" + )); + } + + Ok(s.trim().to_string()) +} + /// A branch name to PRINT, when the payload might not have carried one. /// /// Only for prose and suggested commands, never for a JSON document: a gate reading @@ -525,6 +554,21 @@ mod tests { assert!(nonempty_ref("main").is_ok()); assert!(nonempty_ref("").is_err()); assert!(nonempty_ref("\t").is_err()); + assert_eq!(nonempty_filter("FAILED").unwrap(), "FAILED"); + assert!(nonempty_filter("").is_err()); + assert!(nonempty_filter(" ").is_err()); + // A filter is trimmed and a branch name is not, and the divergence is the point: + // no value in the server's filter vocabulary has a space in it, so ` FAILED` + // could only ever match nothing — while `--branch ' x '` can name a branch that + // exists, and the messages carrying that name also carry a command addressing it. + assert_eq!(nonempty_filter(" FAILED\n").unwrap(), "FAILED"); + assert_eq!(nonempty(" main ").unwrap(), " main "); + assert_eq!(nonempty_ref(" main ").unwrap(), " main "); + // All three open with the phrase the command-tree walk partitions on, so a guard + // that ends up on a branch argument is recognised as one wherever it came from. + for refusal in [nonempty(""), nonempty_ref(""), nonempty_filter("")] { + assert!(refusal.unwrap_err().contains(EMPTY_VALUE_REFUSED)); + } } #[test] From 7ddaa68835bffaf55036a3494f8062cb0f5bd44f Mon Sep 17 00:00:00 2001 From: Gleb Sologub Date: Wed, 2 Sep 2026 11:52:03 +0200 Subject: [PATCH 2/7] docs(dashboards): document the user-attribute default on the time granularity switcher (#11718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(dashboards): document the user-attribute default on the time granularity switcher The controls page documents this setting for filters, parent controls and field switchers; the Time granularity switcher section stopped at the static default, which is now only half the story. Covers what the attribute is matched against (the granularity NAMES, not the localized labels the control renders), what an unusable value does, and where the attribute sits in the precedence order against a URL parameter, a viewer's own pick and a parent control's mapping. * docs(dashboards): scope the granularity attribute-default claims to what the control actually does Review round, all three fair: - The parent-control clause read unconditionally, but an option can leave a child empty — `resolveParentApplications` returns no application for it, so the attribute still seeds the switcher. Scoped, with the third state ("Reset to default", which goes to the SAVED default rather than the viewer's attribute) named as well. - The match target is the switcher's ALLOWED granularities, not the full built-in set; naming that here saves a round trip to the paragraph below. - Nested under "Default granularity" as `####`, so the section's opening "the default above" points at its enclosing heading — the shape the filter's and parent's versions already have. * docs(dashboards): say exactly what Reset to default does to an attribute-seeded switcher Verified against the implementation rather than asserted: `resolveParentApplications` resolves a TIME_GRAIN reset to the child's saved `defaultGrain` and never consults `userAttributeName`, and skips the child entirely when no default is saved — so the attribute value survives that case. Both branches are now pinned by tests in `parent-widget.spec.ts` (cubedevinc/cubejs-enterprise#14627). --- .../dashboards/widgets/controls.mdx | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx index ed8b68add7a4d..cdac5db8a87f1 100644 --- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx +++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx @@ -37,7 +37,7 @@ You can set a default value that's applied when the dashboard loads. Defaults ar There are two ways to set a default: - **Static default** — pick a value (or values) directly in the filter. Every viewer sees the same default. -- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default. [Parent controls](#parent-user-attribute-default) support this too. +- **User attribute default** — resolve the default from the viewer's [user attribute][ref-user-attributes] at load time, so each viewer sees their own personalized default. [Time granularity switchers](#time-granularity-user-attribute-default), [field switchers](#field-switcher-user-attribute-default) and [parent controls](#parent-user-attribute-default) support this too. Static defaults are configured by interacting with the filter in the dashboard builder — the value you select is saved on the widget and applied to every viewer when the dashboard loads. @@ -96,6 +96,41 @@ Custom granularities defined in the [data model][ref-granularities] aren't offer You can configure a default granularity that's applied when the dashboard loads. If no default is set, charts use the granularity that was saved on the underlying report — viewers can still switch granularities, but the dashboard opens with each chart at its original granularity. +#### User attribute default {#time-granularity-user-attribute-default} + +The default above is one granularity for everyone. To give each viewer their own, turn on **User attribute default** in the switcher's settings and pick a [user attribute][ref-user-attributes]. When the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the granularity it names. + +This is how one dashboard opens at the interval each audience works in — daily for the operations team, monthly for the executives who read the same charts — from a single published dashboard. + +To configure it: + + + + In the dashboard builder, open the widget's settings menu and choose **Edit Control**. + + + Below **Visibility**, turn on the **User attribute default** switch. + + + Select the [user attribute][ref-user-attributes] to resolve. Only attributes defined in your account appear in the picker. + + + +The attribute value is matched against the **granularity names** the switcher [allows](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, and `second`, `minute`, `hour` where the dimension exposes them — ignoring case and surrounding spaces, so an attribute reading `Week` resolves to `week`. The names are matched, not the labels the control displays, so one attribute works the same for viewers in every language. + +| Attribute type | How it's applied | +|---|---| +| **String**, **Number** | Matched against the granularity names as a single value. | +| **String array**, **Number array** | The first entry that names an allowed granularity wins. The switcher is single-select, so the rest are ignored. | + +A value that isn't one of the switcher's [allowed granularities](#allowed-granularities) — or is empty, `null`, or unresolvable — is ignored rather than forced, and the control falls back to the [default granularity](#default-granularity). Attributes are set per user and the allowed list per dashboard, so the two can drift apart without anyone editing either; the safe reading of an unusable value is "no opinion". + + +The attribute is resolved for the viewer, not baked into the dashboard. Editing the attribute's value changes what that viewer opens on the next time the dashboard loads; it never rewrites the published dashboard, so the default granularity you set in the builder stays intact for everyone else. + + +Viewers can still switch granularities unless the control's [visibility](#visibility) is set to **Disabled**, and their own pick outranks the attribute for the rest of the session. A granularity passed [in the URL](#sharing-the-current-selection) outranks both, so deep links continue to work. If a [parent control](#parent) drives this switcher and the option the viewer is on maps a granularity to it, that mapping decides the granularity — a mapping the author made for that arrangement is more specific than a per-viewer starting point. An option that [leaves the switcher empty](#children) has no opinion, so the attribute still seeds it; one set to **Reset to default** sends the switcher to the [default granularity](#default-granularity) saved on it rather than re-resolving the attribute, and leaves the switcher untouched when no default granularity was saved. + ## Field switcher A field switcher lets viewers change *which* dimension or measure the charts are built on — swapping a revenue chart's breakdown from **Status** to **City**, or its measure from **Order count** to **Total revenue** — without leaving the dashboard or opening the report. @@ -134,7 +169,7 @@ If the dashboard also has a [time granularity switcher](#time-granularity-switch ### User attribute default {#field-switcher-user-attribute-default} -Like [filters](#user-attribute-default) and [parent controls](#parent-user-attribute-default), a field switcher can start each viewer on their own member. Turn on **User attribute default** in the control's settings and pick a [user attribute][ref-user-attributes]; when the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the member it names. +Like [filters](#user-attribute-default), [time granularity switchers](#time-granularity-user-attribute-default) and [parent controls](#parent-user-attribute-default), a field switcher can start each viewer on their own member. Turn on **User attribute default** in the control's settings and pick a [user attribute][ref-user-attributes]; when the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the member it names. This is how one dashboard opens on the breakdown each audience cares about — a **Breakdown** switcher opening on `region` for one team and `channel` for another, from a single published dashboard. @@ -273,7 +308,7 @@ You can also write these parameters by hand to open a dashboard in a particular What does and doesn't travel in the link: - **Only what the viewer chose.** Values that came from the control's own configuration — a static default, a [default granularity](#default-granularity) — are not written into the URL. Every viewer already gets those from the dashboard itself, and leaving them out means a link stays correct after the dashboard's defaults change. -- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter directly or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes. +- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter or a [time granularity switcher](#time-granularity-user-attribute-default) directly, or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes. - **Filters and granularities together.** Picking both puts both in the link, including when a [parent control](#parent) sets several children at once. A parent control isn't serialized itself — the link carries the values its children ended up with, so the recipient sees the same data while the parent dropdown opens on whatever default it resolves for them, which may not be the option the sharer picked. - **Not the field switcher, yet.** A [field switcher](#field-switcher) has no parameter of its own, so a viewer's member choice doesn't travel in the link. The recipient opens on the control's [default option](#field-switcher-default-option), or on their own [user attribute](#field-switcher-user-attribute-default) where one is set — and on the charts the sharer was looking at, built on a different member than the sharer saw. - **Written out on published dashboards only.** Reading these parameters works anywhere, including [embedded][ref-embed-url-filters] dashboards; it's the writing that is published-only. In the dashboard builder the URL is left to the editing session, so changing a control there doesn't rewrite it. From ef395903b2826d91636ef767f870b9cd5c69133d Mon Sep 17 00:00:00 2001 From: Gleb Sologub Date: Wed, 2 Sep 2026 11:54:53 +0200 Subject: [PATCH 3/7] docs(dashboards): say that a chart opened in the workbook keeps the dashboard's filters (#11719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(dashboards): say that a chart opened in the workbook keeps the dashboard's filters Co-Authored-By: Claude Opus 5 * docs(dashboards): describe the carried filters as they render, in the report's own filter bar Co-Authored-By: Claude Opus 5 * docs(dashboards): name the From dashboard cue, and split the two questions the edit bullet answered Review notes: colour was left as the only way to tell a carried filter from a report one, and the chips do carry a "From dashboard" tooltip — so name it and keep the tint as the at-a-glance cue. The second bullet asked "can I change this?" and answered "edit the dashboard control", which is about what viewers get, not about the person in the workbook wanting another value; those are now separate, and the multi-sentence bullets end in full stops. Co-Authored-By: Claude Opus 5 * docs(dashboards): name the field switcher in the controls list, and keep the third bullet under its lead-in Review nits: the page's controls list predated field switchers and this PR's new paragraph names them, so the omission had become an in-page contradiction; and the third bullet opened on the UI affordance rather than continuing "they are not the report's". Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- .../dashboards/widgets/charts.mdx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx index 67a86aa6f28fe..e20cd823b9a09 100644 --- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx +++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/charts.mdx @@ -13,7 +13,7 @@ If the picker is empty, create the report first in a workbook tab — only publi ## Interaction with controls -Charts respect the [controls][ref-controls] placed on the same dashboard — filters and time granularity switchers. A single control can drive multiple charts at once: its value is applied to every chart whose query references the targeted dimension. +Charts respect the [controls][ref-controls] placed on the same dashboard — filters, time granularity switchers and field switchers. A single control can drive multiple charts at once: its value is applied to every chart whose query references the targeted dimension. If some controls are incompatible with a chart's query, the chart skips them and shows a warning icon. See [Incompatible controls][ref-incompatible-controls] for details. @@ -21,6 +21,24 @@ If some controls are incompatible with a chart's query, the chart skips them and Charts on a published dashboard reflect the most recent published version of the workbook. To change the query, switch the chart type, or restyle the visualization, edit the underlying report in the workbook and publish a new version of the dashboard. +**Edit in Workbook** in the chart's settings menu opens that report directly, on its own workbook tab. + +### Filters carried into the workbook + +The dashboard's [filters][ref-controls] come along, so the report opens showing the same slice of data the chart was showing rather than re-running over everything. + +They sit in the report's filter bar next to its own filters, tinted violet to set them apart, and read the same way — member, operator, value. Hovering one says **From dashboard**. What is different is that they are not the report's: + +- they apply to the results on screen, and are **not** saved to the report. Publishing the workbook again will not pin them onto the chart for everyone. +- their values cannot be changed in the workbook. To explore a different value, add your own filter on the same field, or change the dashboard's control and reopen the chart. +- they can be dropped, but not kept: **Clear filter** on the chip removes it for the rest of the session and the report re-runs without it. Reopening the chart from the dashboard brings the current values back. + +To change what a viewer of the dashboard can filter by, edit the [filter control][ref-controls] there — not the report. + +When the report already filters the same field, both filters apply — exactly as they do on the dashboard, so the numbers match the chart you came from. A carried filter that says precisely what the report already says is left out rather than shown twice. + +Only filters are carried. A [time granularity switcher or field switcher][ref-controls] on the dashboard does not follow into the workbook, so a report opened this way shows its own granularity and its own fields. + ## Title Each chart shows the name of the underlying workbook tab as its title. To rename it, open the tab in the workbook, rename the tab, and republish the dashboard — the new name flows through to every chart backed by that tab. From 2d1450fd8ab54f899d8351c113285fc7493c84ff Mon Sep 17 00:00:00 2001 From: Alex Vasilev Date: Wed, 2 Sep 2026 06:21:38 -0400 Subject: [PATCH 4/7] docs: sync API reference for platform-client v0.6.0 (#11734) * docs: sync API reference for platform-client v0.6.0 Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WS9D79N13zEksXB13WeSRa * fix(docs): hoist nullable-field metadata and de-collide SCIM sidebar groups Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WS9D79N13zEksXB13WeSRa * fix(docs): scope the nullable-metadata hoist to the exact two-branch shape Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WS9D79N13zEksXB13WeSRa * style(docs): trim the hoist function's comment Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WS9D79N13zEksXB13WeSRa --------- Co-authored-by: Claude --- docs-mintlify/api-reference/api.yaml | 742 +++++++++++++------ docs-mintlify/api-reference/changelog.mdx | 23 +- docs-mintlify/api-reference/introduction.mdx | 1 + docs-mintlify/docs.json | 12 +- docs-mintlify/scripts/extract-api.mjs | 28 + 5 files changed, 594 insertions(+), 212 deletions(-) diff --git a/docs-mintlify/api-reference/api.yaml b/docs-mintlify/api-reference/api.yaml index 1b976a0622290..e8c745cd9a9eb 100644 --- a/docs-mintlify/api-reference/api.yaml +++ b/docs-mintlify/api-reference/api.yaml @@ -33,6 +33,7 @@ tags: - name: Workbooks - name: Notifications - name: Workspace + - name: Users - name: Users Admin - name: User Attributes - name: User Attribute Values @@ -951,56 +952,56 @@ paths: oneOf: - minimum: 1 type: integer - description: >- - Optional filter: only return notifications for this numeric dashboard id. Provide - this OR dashboardPublicId, not both. - type: 'null' + description: >- + Optional filter: only return notifications for this numeric dashboard id. Provide this + OR dashboardPublicId, not both. - in: query name: dashboardPublicId schema: oneOf: - type: string - description: >- - Optional filter: only return notifications for this dashboard public id. Provide - this OR dashboardId, not both. - type: 'null' + description: >- + Optional filter: only return notifications for this dashboard public id. Provide this + OR dashboardId, not both. - in: query name: recipientUserId schema: oneOf: - minimum: 1 type: integer - description: >- - Optional filter: only return notifications received by this user, by user id. - Mutually exclusive with recipientEmail and the embed-user filter. - type: 'null' + description: >- + Optional filter: only return notifications received by this user, by user id. Mutually + exclusive with recipientEmail and the embed-user filter. - in: query name: recipientEmail schema: oneOf: - type: string - description: >- - Optional filter: only return notifications received by this user, by email. - Mutually exclusive with recipientUserId and the embed-user filter. - type: 'null' + description: >- + Optional filter: only return notifications received by this user, by email. Mutually + exclusive with recipientUserId and the embed-user filter. - in: query name: recipientEmbedTenantName schema: oneOf: - type: string - description: >- - Optional filter: only return notifications received by this embed user. Required - together with recipientExternalId; mutually exclusive with the user filters. - type: 'null' + description: >- + Optional filter: only return notifications received by this embed user. Required + together with recipientExternalId; mutually exclusive with the user filters. - in: query name: recipientExternalId schema: oneOf: - type: string - description: >- - Optional filter: only return notifications received by this embed user. Required - together with recipientEmbedTenantName; mutually exclusive with the user filters. - type: 'null' + description: >- + Optional filter: only return notifications received by this embed user. Required + together with recipientEmbedTenantName; mutually exclusive with the user filters. - in: query name: after schema: @@ -1906,6 +1907,13 @@ paths: required: true schema: type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateDeploymentTokenInput' + description: CreateDeploymentTokenInput + required: false responses: '200': content: @@ -2202,6 +2210,37 @@ paths: summary: Update workbook dashboard tags: - Workbooks + /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread: + post: + operationId: updatePublishedDashboardAiWidgetThread + parameters: + - in: path + name: deploymentId + required: true + schema: + type: number + - in: path + name: workbookId + required: true + schema: + type: number + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePublishedAiWidgetThreadInput' + description: UpdatePublishedAiWidgetThreadInput + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Dashboard' + description: '' + summary: Update published dashboard AI widget thread + tags: + - Workbooks /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate: post: operationId: duplicateWorkbook @@ -3110,8 +3149,8 @@ paths: schema: oneOf: - type: string - description: Case-insensitive substring match against the embed user’s email or external id. - type: 'null' + description: Case-insensitive substring match against the embed user’s email or external id. - in: query name: after schema: @@ -3822,8 +3861,8 @@ paths: [Cube REST API](https://cube.dev/docs/product/apis-integrations/rest-api)). - Tokens expire after ~24 hours (see `expiresAt`). Issue a fresh one per session or - scheduled refresh — issuing is idempotent and cheap. + Tokens expire after 1 hour (see `expiresAt`). Issue a fresh one per session or scheduled + refresh — issuing is idempotent and cheap. Requires Usage Analytics to be enabled for the account, otherwise `404` is returned. @@ -4116,6 +4155,30 @@ paths: Reapplying an action a user is already in (deactivating a deactivated user) succeeds as a no-op. + /api/v1/users/me/settings: + patch: + operationId: updateMySettings + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserSettingsInput' + description: UserSettings + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/User' + description: '' + summary: Update my settings + tags: + - Users + x-mint: + content: >- + Merge a partial patch into the authenticated user's own settings. Omitted fields are left + as they are; `sheets` merges field-by-field, and an explicit `null` clears it. /api/v1/users/{id}: delete: operationId: deleteUser @@ -4883,16 +4946,35 @@ components: properties: dark: $ref: '#/components/schemas/AppThemeScheme' + deprecated: true + description: 'Deprecated: use palette and logoUrl instead.' light: $ref: '#/components/schemas/AppThemeScheme' + deprecated: true + description: 'Deprecated: use palette and logoUrl instead.' + logoUrl: + type: string + palette: + $ref: '#/components/schemas/AppThemePalette' typography: oneOf: - $ref: '#/components/schemas/AppThemeTypography' - type: 'null' required: + - palette + - logoUrl - light - dark type: object + AppThemeCodePalette: + properties: + saturation: + oneOf: + - maximum: 100 + type: number + minimum: 0 + - type: 'null' + type: object AppThemeFontRef: properties: fontRef: @@ -4903,6 +4985,77 @@ components: - fontRef - fontWeight type: object + AppThemePalette: + properties: + accent: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + base: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + contrastLevel: + oneOf: + - maximum: 100 + type: number + minimum: 0 + - type: 'null' + pastel: + oneOf: + - type: boolean + - type: 'null' + surfaceMode: + oneOf: + - $ref: '#/components/schemas/AppThemeSurfaceMode' + - type: 'null' + themes: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteThemes' + - type: 'null' + type: object + AppThemePaletteSeed: + properties: + color: + oneOf: + - type: string + - type: 'null' + hue: + oneOf: + - maximum: 360 + type: number + minimum: 0 + - type: 'null' + saturation: + oneOf: + - maximum: 100 + type: number + minimum: 0 + - type: 'null' + type: object + AppThemePaletteThemes: + properties: + code: + oneOf: + - $ref: '#/components/schemas/AppThemeCodePalette' + - type: 'null' + danger: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + note: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + success: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + warning: + oneOf: + - $ref: '#/components/schemas/AppThemePaletteSeed' + - type: 'null' + type: object AppThemeScheme: properties: accentColor: @@ -4922,6 +5075,11 @@ components: - contrast - logoUrl type: object + AppThemeSurfaceMode: + enum: + - neutral + - tinted + type: string AppThemeTypography: properties: body: @@ -5380,14 +5538,27 @@ components: - git - cli type: string + CreateDeploymentTokenInput: + properties: + expiresIn: + oneOf: + - maximum: 86400 + type: integer + minimum: 60 + - type: 'null' + description: >- + Token lifetime in seconds, between 60 and 86400. Omit to keep the historical default of + 24 hours (86400). Callers that hold the token outside a browser session — scripts, BI + tools, scheduled jobs — should request a short lifetime and re-issue tokens instead. + type: object CreateEmbedGroupInput: properties: description: oneOf: - maxLength: 500 type: string - description: Optional human-readable description. - type: 'null' + description: Optional human-readable description. name: description: >- Group name, unique within the embed tenant. The `system:` prefix is reserved and @@ -5419,58 +5590,56 @@ components: customCron: oneOf: - type: string - description: Custom cron expression (required if scheduleType is CUSTOM) - type: 'null' + description: Custom cron expression (required if scheduleType is CUSTOM) dashboardId: oneOf: - type: integer - description: >- - Numeric id of the dashboard to run on the schedule. Provide this OR - dashboardPublicId (exactly one). - type: 'null' + description: >- + Numeric id of the dashboard to run on the schedule. Provide this OR dashboardPublicId + (exactly one). dashboardPublicId: oneOf: - type: string - description: >- - Public id of the dashboard to run on the schedule. Provide this OR dashboardId - (exactly one). - type: 'null' + description: >- + Public id of the dashboard to run on the schedule. Provide this OR dashboardId (exactly + one). dayOfMonth: oneOf: - type: integer - description: Day of month (1-31) - type: 'null' + description: Day of month (1-31) dayOfWeek: oneOf: - type: integer - description: Day of week (0-6, Sunday=0) - type: 'null' + description: Day of week (0-6, Sunday=0) filters: oneOf: - items: $ref: '#/components/schemas/DashboardFilterInput' type: array - description: >- - Dimension filters applied to the dashboard when the notification is rendered - (substituted into the screenshot/PDF for every recipient). - type: 'null' + description: >- + Dimension filters applied to the dashboard when the notification is rendered + (substituted into the screenshot/PDF for every recipient). hour: oneOf: - type: integer - description: Hour of the day (0-23) - type: 'null' + description: Hour of the day (0-23) minute: oneOf: - type: integer - description: Minute of the hour (0-59) - type: 'null' + description: Minute of the hour (0-59) notificationAiSummary: oneOf: - type: boolean - description: >- - Include an AI-generated "what changed" summary in the notification body. Off by - default. - type: 'null' + description: Include an AI-generated "what changed" summary in the notification body. Off by default. notificationEnabled: oneOf: - type: boolean @@ -5486,13 +5655,13 @@ components: - items: $ref: '#/components/schemas/DashboardTimeGrainInput' type: array - description: Time-grain overrides applied to the dashboard when the notification is rendered. - type: 'null' + description: Time-grain overrides applied to the dashboard when the notification is rendered. timezone: oneOf: - type: string - description: Timezone for the schedule (e.g. "America/New_York") - type: 'null' + description: Timezone for the schedule (e.g. "America/New_York") required: - scheduleType type: object @@ -6189,12 +6358,14 @@ components: properties: caseSensitive: oneOf: - - description: 'For string filters: whether matching is case-sensitive' + - {} - type: 'null' + description: 'For string filters: whether matching is case-sensitive' endInclusive: oneOf: - - description: 'For between filters: whether the end bound is inclusive' + - {} - type: 'null' + description: 'For between filters: whether the end bound is inclusive' member: description: Dimension path, e.g. "Orders.status" pattern: .+\..+ @@ -6205,20 +6376,21 @@ components: - type: 'null' startInclusive: oneOf: - - description: 'For between filters: whether the start bound is inclusive' + - {} - type: 'null' + description: 'For between filters: whether the start bound is inclusive' value: oneOf: - - description: >- - Filter value. Omit for is_null / is_not_null / is_empty / is_not_empty; provide a - 2-element [start, end] array for between. - oneOf: + - oneOf: - type: string - type: number - type: boolean - type: array items: {} - type: 'null' + description: >- + Filter value. Omit for is_null / is_not_null / is_empty / is_not_empty; provide a + 2-element [start, end] array for between. required: - member type: object @@ -6463,6 +6635,7 @@ components: - FILTER - AI - TIME_GRAIN + - FIELD - PARENT - SPACER - DIVIDER @@ -6503,6 +6676,7 @@ components: - FILTER - AI - TIME_GRAIN + - FIELD - PARENT - SPACER - DIVIDER @@ -6647,8 +6821,8 @@ components: durationMs: oneOf: - type: integer - description: For a phase line, how long the phase took, in milliseconds. - type: 'null' + description: For a phase line, how long the phase took, in milliseconds. level: description: info or error. type: string @@ -6657,8 +6831,8 @@ components: phase: oneOf: - type: string - description: The phase this line belongs to, e.g. dbt-compile. - type: 'null' + description: The phase this line belongs to, e.g. dbt-compile. stream: description: >- Always "system" in this version. "stdout" and "stderr" join it once dbt’s own output is @@ -6691,8 +6865,8 @@ components: message: oneOf: - type: string - description: Human-readable description of what the sync is doing right now. - type: 'null' + description: Human-readable description of what the sync is doing right now. percentComplete: description: >- Rough completion percentage derived from the stage. Progress reporting only — do not @@ -6754,50 +6928,50 @@ components: completedAt: oneOf: - type: string - description: >- - When the sync finished, as an ISO 8601 timestamp, or null while it is still running. - This is stamped by the process that ran the sync whereas startedAt is stamped by the - process that launched it — two different clocks — so for a run that failed moments - after starting, completedAt can even precede startedAt. Use durationMs to show how - long a run took rather than subtracting these. - type: 'null' + description: >- + When the sync finished, as an ISO 8601 timestamp, or null while it is still running. + This is stamped by the process that ran the sync whereas startedAt is stamped by the + process that launched it — two different clocks — so for a run that failed moments after + starting, completedAt can even precede startedAt. Use durationMs to show how long a run + took rather than subtracting these. deploymentId: type: integer durationMs: oneOf: - type: integer - description: >- - How long the sync took, in milliseconds, and the value to use when showing a - duration. Measured from a single clock, so it is never negative — prefer it over - subtracting startedAt from completedAt, which can disagree with it by a small skew. - type: 'null' + description: >- + How long the sync took, in milliseconds, and the value to use when showing a duration. + Measured from a single clock, so it is never negative — prefer it over subtracting + startedAt from completedAt, which can disagree with it by a small skew. errorMessage: oneOf: - type: string - description: Why the sync stopped. Present only for a failed run. - type: 'null' + description: Why the sync stopped. Present only for a failed run. failedPhase: oneOf: - type: string - description: The phase that failed, e.g. dbt-compile. - type: 'null' + description: The phase that failed, e.g. dbt-compile. gitRef: oneOf: - type: string - description: The dbt-repository ref this sync was run against, when pinned. - type: 'null' + description: The dbt-repository ref this sync was run against, when pinned. lastStage: oneOf: - type: string - description: The pipeline stage the run reached, e.g. COMPILING_DBT. - type: 'null' + description: The pipeline stage the run reached, e.g. COMPILING_DBT. phases: oneOf: - items: $ref: '#/components/schemas/DbtSyncRunPhase' type: array - description: Per-phase timings, in the order they ran. Absent while the sync is running. - type: 'null' + description: Per-phase timings, in the order they ran. Absent while the sync is running. startedAt: description: >- When the sync started, as an ISO 8601 timestamp. See completedAt before comparing the @@ -6828,8 +7002,8 @@ components: userId: oneOf: - type: integer - description: The Cube user who started the sync, when a user started it. - type: 'null' + description: The Cube user who started the sync, when a user started it. required: - syncJobId - deploymentId @@ -6875,28 +7049,28 @@ components: cubeCount: oneOf: - type: integer - description: How many cubes the sync generated. - type: 'null' + description: How many cubes the sync generated. generatedFileCount: oneOf: - type: integer - description: How many files the sync wrote to the branch it created. - type: 'null' + description: How many files the sync wrote to the branch it created. macros: oneOf: - type: integer - description: dbt macros found in the manifest. - type: 'null' + description: dbt macros found in the manifest. models: oneOf: - type: integer - description: dbt models found in the manifest. - type: 'null' + description: dbt models found in the manifest. sources: oneOf: - type: integer - description: dbt sources found in the manifest. - type: 'null' + description: dbt sources found in the manifest. type: object DbtSyncRunTriggerContext: properties: @@ -6934,8 +7108,8 @@ components: error: oneOf: - type: string - description: Why the sync stopped. Present only when the status is FAILED. - type: 'null' + description: Why the sync stopped. Present only when the status is FAILED. progress: $ref: '#/components/schemas/DbtSyncProgress' status: @@ -7078,9 +7252,9 @@ components: pagination: oneOf: - $ref: '#/components/schemas/DeploymentsPagination' - deprecated: true - description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.' - type: 'null' + description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.' + deprecated: true required: - items - data @@ -7110,9 +7284,9 @@ components: pagination: oneOf: - $ref: '#/components/schemas/DeploymentsPagination' - deprecated: true - description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.' - type: 'null' + description: 'Deprecated: use `pageInfo` instead. Kept for backward compatibility.' + deprecated: true required: - items - data @@ -7396,11 +7570,11 @@ components: oneOf: - minimum: 0 type: integer - deprecated: true - description: >- - Deprecated: total number of accessible deployments, ignoring pagination. Kept for - backward compatibility. - type: 'null' + description: >- + Deprecated: total number of accessible deployments, ignoring pagination. Kept for + backward compatibility. + deprecated: true data: deprecated: true description: 'Deprecated: use `items` instead. Kept for backward compatibility.' @@ -7423,9 +7597,9 @@ components: oneOf: - minimum: 0 type: integer - deprecated: true - description: 'Deprecated: use `count`. Kept for backward compatibility.' - type: 'null' + description: 'Deprecated: use `count`. Kept for backward compatibility.' + deprecated: true required: - items - data @@ -7554,16 +7728,16 @@ components: oneOf: - minimum: 1 type: integer - description: Numeric id of an existing embed user. Provide this OR `externalId`. - type: 'null' + description: Numeric id of an existing embed user. Provide this OR `externalId`. externalId: oneOf: - minLength: 1 type: string - description: >- - External id of the embed user (the `externalId` passed to `generate-session`). - Provide this OR `embedUserId`. - type: 'null' + description: >- + External id of the embed user (the `externalId` passed to `generate-session`). Provide + this OR `embedUserId`. type: object EmbedGroupMembersInput: properties: @@ -7609,27 +7783,25 @@ components: allowChatWorkspaceAuthoring: oneOf: - type: boolean - description: >- - Whether AI Chat authenticated with this session may create or modify persistent Cube - Workspace content. Set to `false` for headless chat integrations that render answers - in their own UI: ad-hoc data analysis and inline tables/charts remain available, but - the agent cannot save or update standalone explorations/reports, create or modify - workbooks, create or publish dashboards, or direct users to those Cube UI surfaces. - Omit or set to `true` to preserve the session user's role-derived authoring - capabilities; `true` never grants access the user does not already have. This - setting changes only AI Chat tools and instructions. It does not change user roles - or permissions for direct API calls. - type: 'null' + description: >- + Whether AI Chat authenticated with this session may create or modify persistent Cube + Workspace content. Set to `false` for headless chat integrations that render answers in + their own UI: ad-hoc data analysis and inline tables/charts remain available, but the + agent cannot save or update standalone explorations/reports, create or modify workbooks, + create or publish dashboards, or direct users to those Cube UI surfaces. Omit or set to + `true` to preserve the session user's role-derived authoring capabilities; `true` never + grants access the user does not already have. This setting changes only AI Chat tools + and instructions. It does not change user roles or permissions for direct API calls. showDashboardChat: oneOf: - type: boolean - description: >- - Whether embedded published dashboards viewed with this session show the AI chat - (agent panel and launcher bubble). Omit to inherit the account-wide embed setting - (shown by default); `false` hides the chat even if it is enabled account-wide, - `true` shows it even if it is disabled account-wide. Only affects the dashboard - surface. - type: 'null' + description: >- + Whether embedded published dashboards viewed with this session show the AI chat (agent + panel and launcher bubble). Omit to inherit the account-wide embed setting (shown by + default); `false` hides the chat even if it is enabled account-wide, `true` shows it + even if it is disabled account-wide. Only affects the dashboard surface. type: object EmbedSettings: properties: @@ -7947,11 +8119,11 @@ components: oneOf: - minimum: 0 type: integer - deprecated: true - description: >- - Deprecated: total number of accessible folders, ignoring pagination. Kept for - backward compatibility. - type: 'null' + description: >- + Deprecated: total number of accessible folders, ignoring pagination. Kept for backward + compatibility. + deprecated: true data: deprecated: true description: 'Deprecated: use `items` instead. Kept for backward compatibility.' @@ -8013,30 +8185,29 @@ components: - items: $ref: '#/components/schemas/GroupDefinition' type: array - deprecated: true - description: >- - Deprecated and ignored. Global groups can no longer be created through this endpoint - — define them beforehand via the Cube UI or admin API. Still accepted for backward - compatibility (no error), but it has no effect. To create per-embed-tenant groups, - use `tenantGroupDefinitions`. - type: 'null' + description: >- + Deprecated and ignored. Global groups can no longer be created through this endpoint — + define them beforehand via the Cube UI or admin API. Still accepted for backward + compatibility (no error), but it has no effect. To create per-embed-tenant groups, use + `tenantGroupDefinitions`. + deprecated: true groups: oneOf: - items: type: string type: array - description: >- - Global user groups — defined once at the tenant level and shared across every embed - tenant — to assign this embed user to. Use `groups` for **data-model access - control**: each name is placed verbatim into the Cube security context as - `cubeCloud.groups`, where your data model's `access_policy` rules reference it to - gate cubes, views, members, and row-/column-level filters. The groups must already - exist in the tenant (create them via the Cube UI or admin API beforehand) — this - endpoint never creates global groups, and names that do not resolve to an existing - group are rejected. Global groups are NOT shown in an embed tenant’s Creator Mode - UI. To share or organize content inside a single embed tenant, use `tenantGroups` - instead. - type: 'null' + description: >- + Global user groups — defined once at the tenant level and shared across every embed + tenant — to assign this embed user to. Use `groups` for **data-model access control**: + each name is placed verbatim into the Cube security context as `cubeCloud.groups`, where + your data model's `access_policy` rules reference it to gate cubes, views, members, and + row-/column-level filters. The groups must already exist in the tenant (create them via + the Cube UI or admin API beforehand) — this endpoint never creates global groups, and + names that do not resolve to an existing group are rejected. Global groups are NOT shown + in an embed tenant’s Creator Mode UI. To share or organize content inside a single embed + tenant, use `tenantGroups` instead. internalId: oneOf: - type: string @@ -8064,40 +8235,40 @@ components: oneOf: - $ref: '#/components/schemas/EmbedSessionSettings' type: object - description: >- - Per-session overrides for embed behavior, stored in the signed embed token. Each - documented key applies only to this session; omitted keys preserve their existing - default behavior. - type: 'null' + description: >- + Per-session overrides for embed behavior, stored in the signed embed token. Each + documented key applies only to this session; omitted keys preserve their existing + default behavior. tenantGroupDefinitions: oneOf: - items: $ref: '#/components/schemas/GroupDefinition' type: array - description: >- - Idempotently create or update the per-embed-tenant groups referenced by - `tenantGroups`, before they are assigned. Requires `creatorMode: true` and - `embedTenantName`. Use this to declare a tenant’s groups in the same call that - assigns them, so you do not need a separate admin request. Applies only to - per-embed-tenant groups; global groups must be defined beforehand. - type: 'null' + description: >- + Idempotently create or update the per-embed-tenant groups referenced by `tenantGroups`, + before they are assigned. Requires `creatorMode: true` and `embedTenantName`. Use this + to declare a tenant’s groups in the same call that assigns them, so you do not need a + separate admin request. Applies only to per-embed-tenant groups; global groups must be + defined beforehand. tenantGroups: oneOf: - items: type: string type: array - description: >- - Per-embed-tenant user groups — scoped to the single embed tenant named by - `embedTenantName` — to assign this embed user to. Use `tenantGroups` for **content - sharing and organization within one embed tenant**: for example, so a creator can - share a workbook, dashboard, or folder with a group of that tenant’s users. These - are the only groups shown in the embed tenant’s Creator Mode UI. Requires - `creatorMode: true` and `embedTenantName`. Define the groups beforehand — or in the - same request — via `tenantGroupDefinitions`. In the Cube security context they - appear namespaced as `system:tenant:{embedTenantName}:group:{groupName}`, so a - tenant group can never collide with — or be mistaken for — a global `groups` entry - of the same name. For organization-wide data-model access policies, use `groups`. - type: 'null' + description: >- + Per-embed-tenant user groups — scoped to the single embed tenant named by + `embedTenantName` — to assign this embed user to. Use `tenantGroups` for **content + sharing and organization within one embed tenant**: for example, so a creator can share + a workbook, dashboard, or folder with a group of that tenant’s users. These are the only + groups shown in the embed tenant’s Creator Mode UI. Requires `creatorMode: true` and + `embedTenantName`. Define the groups beforehand — or in the same request — via + `tenantGroupDefinitions`. In the Cube security context they appear namespaced as + `system:tenant:{embedTenantName}:group:{groupName}`, so a tenant group can never collide + with — or be mistaken for — a global `groups` entry of the same name. For + organization-wide data-model access policies, use `groups`. userAttributeDefinitions: oneOf: - items: @@ -8379,8 +8550,8 @@ components: - items: $ref: '#/components/schemas/DashboardFilter' type: array - description: Dimension filters applied when the notification is rendered. - type: 'null' + description: Dimension filters applied when the notification is rendered. humanReadableSchedule: description: Human-readable description of the cron schedule type: string @@ -8400,8 +8571,8 @@ components: - items: $ref: '#/components/schemas/DashboardTimeGrain' type: array - description: Time-grain overrides applied when the notification is rendered. - type: 'null' + description: Time-grain overrides applied when the notification is rendered. timezone: type: string required: @@ -8460,45 +8631,45 @@ components: channelId: oneOf: - type: string - description: Slack channel id (for type=SLACK) - type: 'null' + description: Slack channel id (for type=SLACK) channelName: oneOf: - type: string - description: Slack channel display name (optional, for type=SLACK) - type: 'null' + description: Slack channel display name (optional, for type=SLACK) email: oneOf: - type: string - description: Main user email (for type=USER; provide this OR userId) - type: 'null' + description: Main user email (for type=USER; provide this OR userId) embedTenantName: oneOf: - type: string - description: Embed tenant name (for type=EMBED_USER) - type: 'null' + description: Embed tenant name (for type=EMBED_USER) externalId: oneOf: - type: string - description: Embed user external id (for type=EMBED_USER) - type: 'null' + description: Embed user external id (for type=EMBED_USER) groups: oneOf: - items: type: string type: array - description: >- - Embed user groups (type=EMBED_USER). Must reference groups that already exist; - drives per-recipient access when the report is rendered. - type: 'null' + description: >- + Embed user groups (type=EMBED_USER). Must reference groups that already exist; drives + per-recipient access when the report is rendered. securityContext: oneOf: - type: object additionalProperties: true - description: >- - Embed user security context (type=EMBED_USER). Applied for per-recipient row-level - security when the report is rendered. - type: 'null' + description: >- + Embed user security context (type=EMBED_USER). Applied for per-recipient row-level + security when the report is rendered. type: $ref: '#/components/schemas/NotificationRecipientInputType' userAttributes: @@ -8506,15 +8677,15 @@ components: - items: $ref: '#/components/schemas/UserAttributeInput' type: array - description: >- - Embed user attribute values (type=EMBED_USER). Names must reference attribute - definitions that already exist. - type: 'null' + description: >- + Embed user attribute values (type=EMBED_USER). Names must reference attribute + definitions that already exist. userId: oneOf: - type: integer - description: Main user id (for type=USER; provide this OR email) - type: 'null' + description: Main user id (for type=USER; provide this OR email) required: - type type: object @@ -8808,6 +8979,89 @@ components: - before - after type: string + Policy: + properties: + actions: + items: + enum: + - All + - DeploymentsManage + - DeploymentCreate + - DeploymentRead + - DeploymentUpdate + - DeploymentDelete + - SecretsManage + - DownloadData + - PlaygroundRead + - SchemaRead + - SchemaUpdate + - SchemaUpdateDevBranches + - APMRead + - PreAggregationBuild + - AlertsCreate + - AlertsRead + - AlertsUpdate + - AlertsDelete + - AuditLogManage + - BillingRead + - SqlRunnerRead + - DataAssetsRead + - DataAssetsManage + - CubeNetworkConnect + - ReportRead + - ReportEdit + - ReportManage + - WorkbookManage + - WorkbookRead + - WorkbookEdit + - ChatThreadRead + - AgentManage + - AgentRead + - AgentSpaceManage + - AgentAdmin + - DeploymentAgentRead + - OAuthIntegrationsManage + - OAuthIntegrationsIssueTokens + - McpToolsManage + - AIBIDevelop + - AIBIExplore + - AIBIView + - ChartPalettesManage + - DashboardThemesManage + - AIBIDeveloper + - AIBIUser + - AIBIViewer + - EmbedDeploymentRead + - EmbedDashboardRead + - FolderRead + - FolderEdit + - FolderManage + type: string + type: array + resourceType: + $ref: '#/components/schemas/PolicyResourceType' + resources: + items: + type: string + type: array + required: + - resourceType + - actions + - resources + type: object + PolicyResourceType: + enum: + - Global + - Deployment + - Report + - ReportFolder + - Agent + - AgentSpace + - Workbook + - Dashboard + - Folder + - ChatThread + type: string PostTokenBySessionIdInput: properties: sessionId: @@ -8832,13 +9086,13 @@ components: oneOf: - format: email type: string - description: >- - Email address, shown wherever the user is listed and searchable through `GET - /embed-tenants/{embedTenantName}/users`. Must be a valid address, and is stored - lowercased. Omit it and Cube derives a synthetic `{externalId}@cubecloud.dev` - placeholder instead, which is what makes a user hard to recognise in a list. - Supplying it again later updates the stored address. - type: 'null' + description: >- + Email address, shown wherever the user is listed and searchable through `GET + /embed-tenants/{embedTenantName}/users`. Must be a valid address, and is stored + lowercased. Omit it and Cube derives a synthetic `{externalId}@cubecloud.dev` + placeholder instead, which is what makes a user hard to recognise in a list. Supplying + it again later updates the stored address. externalId: description: >- The id your own system knows this user by — the same `externalId` you will pass to @@ -8851,31 +9105,30 @@ components: - items: type: string type: array - description: >- - Global, account-wide groups (the `groups` field of `generate-session`) that gate - data-model access. They must already exist. Supplying the field REPLACES the user’s - global groups; omit it to leave them untouched, pass `[]` to clear them. - type: 'null' + description: >- + Global, account-wide groups (the `groups` field of `generate-session`) that gate + data-model access. They must already exist. Supplying the field REPLACES the user’s + global groups; omit it to leave them untouched, pass `[]` to clear them. tenantGroups: oneOf: - items: type: string type: array - description: >- - Groups belonging to this embed tenant (the `tenantGroups` field of - `generate-session`), which scope content sharing and organization within the tenant. - Create them first via `POST /embed-tenants/{embedTenantName}/groups`. Supplying the - field REPLACES the user’s tenant groups; omit it to leave them untouched, pass `[]` - to clear them. - type: 'null' + description: >- + Groups belonging to this embed tenant (the `tenantGroups` field of `generate-session`), + which scope content sharing and organization within the tenant. Create them first via + `POST /embed-tenants/{embedTenantName}/groups`. Supplying the field REPLACES the user’s + tenant groups; omit it to leave them untouched, pass `[]` to clear them. userProfile: oneOf: - $ref: '#/components/schemas/EmbedUserProfile' type: object - description: >- - Display name and avatar. `displayName` is the name shown wherever the user appears, - including on content they author. Omitted fields keep their current value. - type: 'null' + description: >- + Display name and avatar. `displayName` is the name shown wherever the user appears, + including on content they author. Omitted fields keep their current value. required: - externalId type: object @@ -9071,8 +9324,8 @@ components: embedTenantName: oneOf: - type: string - description: Embed tenant name (required for type=EMBED_USER; resolves the storage partition) - type: 'null' + description: Embed tenant name (required for type=EMBED_USER; resolves the storage partition) id: description: >- Recipient id: userId (type=USER), embedUserId (type=EMBED_USER), or channelId @@ -9137,6 +9390,10 @@ components: type: string createdBy: type: integer + currentQueryChecksum: + oneOf: + - type: string + - type: 'null' deploymentId: type: integer description: @@ -9286,6 +9543,10 @@ components: oneOf: - type: integer - type: 'null' + queryChecksum: + oneOf: + - type: string + - type: 'null' refreshedAt: oneOf: - type: string @@ -9304,6 +9565,10 @@ components: oneOf: - type: string - type: 'null' + syncStatus: + oneOf: + - $ref: '#/components/schemas/ReportPlacementSyncStatus' + - type: 'null' workbookId: type: string workbookName: @@ -9319,6 +9584,12 @@ components: - GOOGLE_SHEETS - EXCEL type: string + ReportPlacementSyncStatus: + enum: + - UP_TO_DATE + - CHANGED + - UNKNOWN + type: string ReportSnapshot: properties: description: @@ -9409,11 +9680,11 @@ components: oneOf: - minimum: 0 type: integer - deprecated: true - description: >- - Deprecated: total number of accessible reports, ignoring pagination. Kept for - backward compatibility. - type: 'null' + description: >- + Deprecated: total number of accessible reports, ignoring pagination. Kept for backward + compatibility. + deprecated: true data: deprecated: true description: 'Deprecated: use `items` instead. Kept for backward compatibility.' @@ -9556,6 +9827,29 @@ components: - type: boolean - type: 'null' type: object + SheetsUserSettingsInput: + properties: + autoRunQueryOnChange: + oneOf: + - type: boolean + - type: 'null' + openExplorationFromSheet: + oneOf: + - type: boolean + - type: 'null' + revealSheetOnOpen: + oneOf: + - type: boolean + - type: 'null' + showAppliedFilters: + oneOf: + - type: boolean + - type: 'null' + suppressDuplicateValues: + oneOf: + - type: boolean + - type: 'null' + type: object SourceTreeResponse: properties: data: @@ -9587,12 +9881,12 @@ components: ref: oneOf: - type: string - description: >- - Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA. - Overrides the branch saved on the deployment’s dbt git integration for this sync - only, so CI can sync the ref under review (e.g. a pull request’s head branch) - instead of the tracked branch. The generated Cube branch is named after it. - type: 'null' + description: >- + Git ref in the dbt repository to sync from — a branch or a tag, NOT a commit SHA. + Overrides the branch saved on the deployment’s dbt git integration for this sync only, + so CI can sync the ref under review (e.g. a pull request’s head branch) instead of the + tracked branch. The generated Cube branch is named after it. type: object StartDevModeRequest: properties: @@ -9871,8 +10165,8 @@ components: oneOf: - maxLength: 500 type: string - description: New description. Pass an empty string to clear it. Omit to leave it unchanged. - type: 'null' + description: New description. Pass an empty string to clear it. Omit to leave it unchanged. type: object UpdateFolderInput: properties: @@ -9905,10 +10199,10 @@ components: - items: $ref: '#/components/schemas/DashboardFilterInput' type: array - description: >- - Dimension filters applied to the dashboard when the notification is rendered. - Replaces the existing set when provided. - type: 'null' + description: >- + Dimension filters applied to the dashboard when the notification is rendered. Replaces + the existing set when provided. hour: oneOf: - type: integer @@ -9916,8 +10210,8 @@ components: isEnabled: oneOf: - type: boolean - description: Enable or disable the schedule - type: 'null' + description: Enable or disable the schedule minute: oneOf: - type: integer @@ -9925,10 +10219,8 @@ components: notificationAiSummary: oneOf: - type: boolean - description: >- - Include an AI-generated "what changed" summary in the notification body. Off by - default. - type: 'null' + description: Include an AI-generated "what changed" summary in the notification body. Off by default. notificationEnabled: oneOf: - type: boolean @@ -9946,10 +10238,10 @@ components: - items: $ref: '#/components/schemas/DashboardTimeGrainInput' type: array - description: >- - Time-grain overrides applied to the dashboard when the notification is rendered. - Replaces the existing set when provided. - type: 'null' + description: >- + Time-grain overrides applied to the dashboard when the notification is rendered. + Replaces the existing set when provided. timezone: oneOf: - type: string @@ -10036,6 +10328,20 @@ components: maxLength: 128 - type: 'null' type: object + UpdatePublishedAiWidgetThreadInput: + properties: + checksum: + oneOf: + - type: string + - type: 'null' + threadId: + type: string + widgetId: + type: string + required: + - widgetId + - threadId + type: object UpdateReportInput: properties: endResultCell: @@ -10166,10 +10472,10 @@ components: expiresAt: oneOf: - type: string - description: >- - When the token expires, as an ISO-8601 timestamp. Request a new token before this - moment — issuing one is idempotent and cheap. - type: 'null' + description: >- + When the token expires, as an ISO-8601 timestamp. Request a new token before this moment + — issuing one is idempotent and cheap. token: description: >- A short-lived JWT for the Usage Analytics deployment, carrying this tenant’s security @@ -10271,6 +10577,15 @@ components: - format: date-time type: string - type: 'null' + userPolicies: + oneOf: + - items: + $ref: '#/components/schemas/Policy' + type: array + - type: 'null' + description: >- + The caller's effective resource policies: direct grants, organization-wide grants, and + grants inherited from their groups. Only populated by `GET /api/v1/users/me`. username: type: string required: @@ -10686,10 +11001,19 @@ components: oneOf: - type: integer - type: 'null' + description: >- + Deprecated and ignored. The changelog now arrives in the notification inbox, which + carries its own read state. Accepted for backward compatibility but never read or + written. + deprecated: true locale: oneOf: - type: string - type: 'null' + sheets: + oneOf: + - $ref: '#/components/schemas/SheetsUserSettingsInput' + - type: 'null' theme: oneOf: - type: string @@ -10943,11 +11267,11 @@ components: oneOf: - minimum: 0 type: integer - deprecated: true - description: >- - Deprecated: total number of accessible workbooks, ignoring pagination. Kept for - backward compatibility. - type: 'null' + description: >- + Deprecated: total number of accessible workbooks, ignoring pagination. Kept for backward + compatibility. + deprecated: true data: deprecated: true description: 'Deprecated: use `items` instead. Kept for backward compatibility.' diff --git a/docs-mintlify/api-reference/changelog.mdx b/docs-mintlify/api-reference/changelog.mdx index b7a77669bbf9b..dde2b759b6d3e 100644 --- a/docs-mintlify/api-reference/changelog.mdx +++ b/docs-mintlify/api-reference/changelog.mdx @@ -7,6 +7,27 @@ rss: true {/* GENERATED FILE — do not edit by hand. */} {/* Run scripts/extract-changelog.js against the platform client CHANGELOG.md. */} + + ### Added + + - `User` (`GET /api/v1/users/me`, `UsersPublicController.getMe`) gained `userPolicies` — the caller's effective resource policies: direct grants, organization-wide grants and grants inherited from their groups. This is the set the console has always read over GraphQL; on REST it lets an embedded Creator Mode session resolve a workbook another embed user shared with it, which previously granted nothing beyond viewing. New schemas: `Policy`, `PolicyResourceType`. + - Re-added `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (`widgetId`, `threadId`, optional `checksum`) — reverting the `0.4.0` removal. Regenerating an AI-analysis widget on a **published** dashboard persists the new thread id (+ checksum) back to the published config so an immediate reload shows the fresh result instead of the stale one. It stores **only** the thread id and checksum — never summary text (CUB-4031). + - `PATCH /api/v1/users/me/settings` (`UsersPublicController.updateMySettings`) — merge a partial patch into your own settings; omitted fields are left as they are, and `sheets` merges field-by-field (an explicit `null` clears it). `UserSettingsInput` gained `sheets`. New schema: `SheetsUserSettingsInput` (`autoRunQueryOnChange`, `openExplorationFromSheet`, `revealSheetOnOpen`, `showAppliedFilters`, `suppressDuplicateValues`). + - `POST /api/v1/deployments/{id}/token` (`DeploymentsPublicController.deploymentToken`) now accepts an optional body to request a shorter token lifetime than the 24-hour default. New schema: `CreateDeploymentTokenInput` (`expiresIn`, 60–86400 seconds) — recommended for tokens held outside a browser session (scripts, BI tools, scheduled jobs). + - `AppTheme` / `AppThemeResponse` (`GET /api/v1/app-theme`, `GET /api/v1/app-config`) gained `palette` and `logoUrl` — the new theme model: base/accent color seeds, contrast level, a pastel toggle, surface mode, and per-semantic color overrides (danger/warning/success/note/code). New schemas: `AppThemePalette`, `AppThemePaletteSeed`, `AppThemePaletteThemes`, `AppThemeCodePalette`, `AppThemeSurfaceMode`. + - `DashboardWidgetDtoType` / `DashboardWidgetInputType` gained a new `"FIELD"` enum value, for the Field switcher dashboard control. + - `ReportPlacement` gained `queryChecksum` and `syncStatus` (new schema `ReportPlacementSyncStatus`: `"UP_TO_DATE"` | `"CHANGED"` | `"UNKNOWN"`), and `Report` gained `currentQueryChecksum` — together they let a client tell whether the cells at a placement still reflect the exploration as it stands now. + + ### Changed + + - `POST /api/v1/usage-analytics/token` — corrected docs: tokens expire after 1 hour, not ~24 hours as previously stated. `expiresAt` on the response has always reflected the real value. + + ### Deprecated + + - `AppTheme.light` / `AppTheme.dark` — use `palette` and `logoUrl` instead. + - `UserSettingsInput.lastSeenChangelogId` — ignored; the changelog now arrives in the notification inbox, which carries its own read state. Accepted for backward compatibility but never read or written. + + ### Added @@ -72,7 +93,7 @@ rss: true ### Removed - - **BREAKING:** `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (added in `0.2.0`). It persisted an AI-analysis thread id + checksum into the published dashboard config — the mechanism that caused read-only/anonymous viewers to hit `WorkbookEdit`/403s and let one viewer's filter state clobber the shared config (CUB-3898). AI-analysis results are now cached server-side keyed by dashboard state, so no client-facing endpoint replaces it. This is a deliberate breaking removal; SDK consumers referencing the operation or schema should drop those references. + - **BREAKING:** `POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread` (`WorkbooksPublicController.updatePublishedDashboardAiWidgetThread`) and its `UpdatePublishedAiWidgetThreadInput` schema (added in `0.2.0`). It persisted an AI-analysis thread id + checksum into the published dashboard config — the mechanism that caused read-only/anonymous viewers to hit `WorkbookEdit`/403s and let one viewer's filter state clobber the shared config (CUB-3898). AI-analysis results are recovered by replaying the chat thread whose id the dashboard config stores, so no client-facing endpoint replaces it (CUB-4031). This is a deliberate breaking removal; SDK consumers referencing the operation or schema should drop those references. diff --git a/docs-mintlify/api-reference/introduction.mdx b/docs-mintlify/api-reference/introduction.mdx index ac5db19a18b47..22dd526ff875e 100644 --- a/docs-mintlify/api-reference/introduction.mdx +++ b/docs-mintlify/api-reference/introduction.mdx @@ -85,6 +85,7 @@ Resources by entity: | [Workbooks](/api-reference/workbooks/get-workbooks) | `/api/v1/deployments/{deploymentId}/workbooks` | v1 | | [Notifications](/api-reference/notifications/list-scheduled-notifications) | `/api/v1/deployments/{deploymentId}/notifications` | v1 | | [Workspace](/api-reference/workspace/list-shared-workspace-items) | `/api/v1/deployments/{deploymentId}` | v1 | +| [Users](/api-reference/users/update-my-settings) | `/api/v1/users/me/settings` | v1 | | [Users Admin](/api-reference/users-admin/create-user) | `/api/v1/users` | v1 | | [User Attributes](/api-reference/user-attributes/get-user-attributes) | `/api/v1/user-attributes` | v1 | | [User Attribute Values](/api-reference/user-attribute-values/upsert-user-attribute-value) | `/api/v1/user-attribute-values` | v1 | diff --git a/docs-mintlify/docs.json b/docs-mintlify/docs.json index 30181fd50ee9f..12a948fbfabd9 100644 --- a/docs-mintlify/docs.json +++ b/docs-mintlify/docs.json @@ -901,6 +901,7 @@ "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "DELETE /api/v1/deployments/{deploymentId}/workbooks/{workbookId}", "PUT /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard", + "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/dashboard/ai-widget-thread", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/duplicate", "POST /api/v1/deployments/{deploymentId}/workbooks/{workbookId}/publish" ] @@ -930,6 +931,13 @@ "POST /api/v1/deployments/{deploymentId}/workspace/move" ] }, + { + "group": "Users", + "openapi": "/api-reference/api.yaml", + "pages": [ + "PATCH /api/v1/users/me/settings" + ] + }, { "group": "Users Admin", "openapi": "/api-reference/api.yaml", @@ -1057,7 +1065,7 @@ ] }, { - "group": "Users", + "group": "Users (SCIM)", "openapi": "/api-reference/scim.yaml", "pages": [ "GET /scim/v2/Users", @@ -1069,7 +1077,7 @@ ] }, { - "group": "Groups", + "group": "Groups (SCIM)", "openapi": "/api-reference/scim.yaml", "pages": [ "GET /scim/v2/Groups", diff --git a/docs-mintlify/scripts/extract-api.mjs b/docs-mintlify/scripts/extract-api.mjs index 67b29fca63b2d..31c08489804fd 100644 --- a/docs-mintlify/scripts/extract-api.mjs +++ b/docs-mintlify/scripts/extract-api.mjs @@ -425,6 +425,34 @@ if (missing.length) { process.exit(1); } +// 2b. Hoist `description`/`deprecated` off a nullable field's non-null `oneOf` +// branch, where class-validator-jsonschema puts them. Renderers read both off the +// property schema, not a branch, so they otherwise never render. Scoped to exactly +// a two-branch, one-bare-null shape, so a genuine polymorphic oneOf — several real +// alternatives, each with its own description — is left alone. +function hoistNullableMeta(node) { + if (Array.isArray(node)) { node.forEach(hoistNullableMeta); return; } + if (!node || typeof node !== 'object') return; + const branches = node.oneOf; + if (Array.isArray(branches) && branches.length === 2) { + const isBareNull = (b) => b && Object.keys(b).length === 1 && b.type === 'null'; + const branch = isBareNull(branches[0]) ? branches[1] : isBareNull(branches[1]) ? branches[0] : null; + if (branch && typeof branch === 'object') { + if (branch.description !== undefined && node.description === undefined) { + node.description = branch.description; + delete branch.description; + } + if (branch.deprecated !== undefined && node.deprecated === undefined) { + node.deprecated = branch.deprecated; + delete branch.deprecated; + } + } + } + for (const v of Object.values(node)) hoistNullableMeta(v); +} +hoistNullableMeta(paths); +hoistNullableMeta(schemas); + // 3. Determine tag set + order (preferred order first, then any extras A–Z). const presentTags = new Set(); for (const val of Object.values(paths)) { From 57c169246514174ffdd9fe77e44f59a09a0cf594 Mon Sep 17 00:00:00 2001 From: Gleb Sologub Date: Wed, 2 Sep 2026 14:33:05 +0200 Subject: [PATCH 5/7] docs(dashboards): a parent control can drive a field switcher (#11721) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(dashboards): a parent control can drive a field switcher CUB-4269 removes the limitation the Controls page documented in two places: a field switcher can now be a parent control's child, so it appears in the Children picker, its mapping row renders its own member picker, and it carries the same mapping-status indicator the other child controls do. Also documents `ms_`, the field switcher's own URL parameter. It shipped with the control (CUB-454/CUB-4159) but the page still said a viewer's choice was not carried in the link. It travels in a shared link and — unlike `f_` / `tg_` — not in a scheduled export or a screenshot, whose URLs are rebuilt from a server-side allowlist that has no entry for it. * docs(dashboards): resolve the ms_ contradiction the first pass left behind Review caught that "Sharing the current selection" still carried a bullet saying a field switcher "has no parameter of its own" — nine lines below the `ms_` row this branch adds. The page contradicted itself on its most load-bearing new claim. That bullet now states what actually happens, keeping the boundary the new Note draws: the pick travels in a shared link, and does not survive a scheduled export or a screenshot, whose URLs are rebuilt server-side from an allowlist with no field-switcher parameter. The bullet above it widens from "filters and granularities" to every control type, since a parent's children can now include a switcher. Also widens the table's explanatory paragraph from "semantic view and dimension" to "member" and says the right-hand side of `ms_` is an internal name too — and a measure when the switcher's Field Type is Measure. And adds the missing `ms_` row to the embedded-dashboard URL table, which the controls page sends readers to for "the same format" and which still listed only `f_` and `tg_`. * docs(dashboards): close the remaining two-of-three control enumerations Review found the same pattern in three more places, all of which now under-count the control types: - the "Sharing the current selection" intro still promised the recipient "the same filters and granularities", eighteen lines above the `ms_` row; - the "parameter is ignored" enumeration listed two reject cases where there are now three — a selected member outside the switcher's Alternatives is dropped the same way an unlisted granularity is (`dashboard-member-switchers.ts` drops a URL member that `isSelectable` rejects), and the page already commits to exactly that behaviour for the attribute path; - the parent user-attribute example named the two child types it used to have. On the embed page, the prose quoted `` / `` as literals, so neither of the `ms_` row's own placeholders was covered by the rule governing them — it now describes the parts instead — and the setting is named **Field Type**, as the control spells it, on the page a reader reaches without that context. Its reject enumeration gains the member case too. Deliberately not touched: the embed page's section heading (and the link text mirroring it) still say "filters and granularities". Renaming a published heading is a wider change than this PR, and its anchor is explicit, so the wording is a follow-up rather than a correctness gap. * docs(dashboards): say member, not dimension, where the clause now governs ms_ Follow-through on the reject enumeration this branch just widened. The first case in that same sentence still said "no matching control for that dimension", but the clause governs all three parameters now and an `ms_` key is a measure whenever the switcher's Field Type is Measure — the case the paragraph two above goes out of its way to spell out. Both pages now say "member", matching the vocabulary shift already made at the table. On the embed page, **Alternatives** is also bolded and linked to the field switcher section, so it reads like its sibling in the same sentence (the allowed-granularities link) rather than as plain prose — that page is the one a reader reaches without the controls page's context. * docs(dashboards): document the field switcher's precedence, and stop saying it twice Three follow-ups from review, all created by this branch rather than pre-existing: - The field switcher's **User attribute default** section was the only one of the three that never said how the attribute interacts with a URL parameter or a parent control — and before this branch neither applied to it, so the gap is ours. It now states the whole chain, matching the time granularity twin. Verified rather than mirrored: `dashboard-member-switchers.ts` seeds `urlMember ?? attributeMember ?? defaultOption ?? replacedMember`, an unmapped parent cell emits no application at all (so the attribute's seed stands), and RESET targets `defaultOption ?? replaced member` without re-resolving the attribute. - The field switcher `` had become a second copy of the Sharing bullet, which is the fuller version and sits in the section that owns the topic — `docs-mintlify/CLAUDE.md:267` ("Say it once"). The callout keeps the pointer and drops the mechanism. - Re-wrapped the embed paragraph to the file's ~80 columns; taking the earlier suggestion verbatim had left it ragged with "For" stranded. * docs(dashboards): name the resolution in the Note, and finish the re-flow Trimming the Note left it saying an export renders "the control's default member", where the bullet it defers to says "the default option — or the recipient's own user attribute where one is set". Inconsistent about the same mechanism, and read most often by an author who has just turned the attribute default on two sections above. It now names the resolution instead of one of its outcomes: those surfaces open on whatever member the control resolves for the recipient on its own. The previous re-wrap fixed the first three lines but left "Type** is **Measure**. For" at 26 columns — the stranded break that was the point of the report. Closing it needs the following sentence pulled up, past where the suggestion block reached; the paragraph now flows at ~78 with only the URL line long, as it already was. * docs(dashboards): the replaced member is offered too, so ms_ back to it is honoured Both reject clauses said a member "isn't among the field switcher's Alternatives", which is narrower than the code: `isSelectable` is `candidate === member || options.includes(candidate)`, so the replaced member is selectable as well. `ms_orders.status=status` — the hand-written way to deep-link back to the original view — is honoured, and both pages told the reader it was ignored. The embed page matters more here, since it is about hand-writing the parameter, but the controls page's sentence sits directly under "you can also write these parameters by hand", so both are read by someone acting on the rule. Left as-is: the attribute paragraph's identical shorthand, which is pre-existing and sits in a passage that is not about authoring a value. * docs(dashboards): a parent is the third thing an export can resolve a switcher to The Sharing bullet named the default option and the recipient's attribute, but a parent control driving the switcher is the path this branch itself added — and in a server-rendered copy there is no `ms_`, so the parent opens on its own default (or the recipient's attribute) and drives its children. Verified both halves in `dashboard-parent.ts`: a plain `defaultOptionId` needs no mount-time write because `persistSelection` already wrote the mapped member into the child's own `defaultOption`, and a user-attribute parent pushes on mount (`applyParentOption(..., { isSeeding: true })`) because no saved child config can carry a per-viewer value. Either way the switcher shows what the parent maps. Same over-statement as the Note fixed one commit ago; here the enumeration is worth keeping since this is the section that owns the topic, so it is extended rather than replaced. * docs(dashboards): name the setting in the Reset row, not the outcome The field switcher's cell said Reset puts it back to "the member it opens on", which for a viewer with a user-attribute default IS the attribute's member — the exact resolution the clause added one commit ago says Reset does not perform. `getSwitcherDefaultMember` returns `defaultOption ?? replaced member` and never consults the attribute, so the row now names the setting, as its two siblings already did. The default-granularity link goes in alongside for symmetry: a reader who needs "what is the default option" needs the same answer for granularity. * docs(dashboards): the mapping picker offers the replaced member too Last instance of the Alternatives narrowing, and here it made the sentence contradict its own next clause: the picker was described as Alternatives-only while the following sentence promised "exactly the ones a viewer could pick". Checked the editor rather than the analogy, as the review asked: `ParentChildValueControl` builds the row's options with `resolveSwitcherOptions`, passing `memberName: parsed.memberName`, and that helper PREPENDS the replaced member (`[memberName, ...configured]`). So the picker does offer it, and "exactly" was the accurate half. Also re-read the section as one claim rather than fixing this line alone — L150 (offered set), L176 (precedence), L193, L225 (Reset), L314 and L317 now tell the same story. L176's "outranks both" is left as-is deliberately: it mirrors the time-granularity twin's established phrasing at L132, so changing only the switcher's copy would introduce a divergence rather than remove one. * docs(dashboards): drop the export carve-out — CUB-4289 landed Verified on master rather than taking the report: #14633 merged at 11:10 today and `ALLOWED_CAPTURE_PARAM_PREFIXES` is now `['f_', 'tg_', 'ms_']`, with `buildFilterQueryParams` taking `memberSwitcherState` as a required argument and a `DashboardMemberSwitcherInput` carrying the selection through scheduled runs. So a field switcher's member now reaches every server-rendered copy, and both sentences this branch added about it not surviving one are false. The `` is deleted rather than inverted: it existed only to record limitations, and with the last one gone there is no caveat to call out — the positive statement already lives in the section that owns the topic, so inverting it here would re-create the duplication removed in 74e5fd3. The Sharing bullet goes for the same reason: its entire content was the carve-out, and "Every control's pick, together" already covers all three types. Deliberately NOT documenting the new export behaviour here — cube-js/cube#11723 owns that and describes it more fully (link carries only what the viewer chose; an export carries every switcher and outranks an attribute; a scheduled export renders the member stored on the schedule). --- .../dashboards/widgets/controls.mdx | 28 ++++++++----------- docs-mintlify/embedding/iframe/dashboards.mdx | 17 +++++++---- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx index cdac5db8a87f1..43c3b839b1731 100644 --- a/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx +++ b/docs-mintlify/docs/explore-analyze/dashboards/widgets/controls.mdx @@ -173,7 +173,7 @@ Like [filters](#user-attribute-default), [time granularity switchers](#time-gran This is how one dashboard opens on the breakdown each audience cares about — a **Breakdown** switcher opening on `region` for one team and `channel` for another, from a single published dashboard. -The attribute seeds the *selection*, exactly as the [default option](#field-switcher-default-option) does, and loses to a pick the viewer has already made. A value that isn't among the **Alternatives** is ignored rather than forced: the attribute is set per user and the options are set per dashboard, so the two can drift apart without anyone editing either, and the safe reading of an unusable value is "no opinion" — the control falls back to the default option. +The attribute seeds the *selection*, exactly as the [default option](#field-switcher-default-option) does, and loses to a pick the viewer has already made. A member passed [in the URL](#sharing-the-current-selection) outranks both, so deep links keep working. If a [parent control](#parent) drives this switcher and the option the viewer is on maps a member to it, that mapping decides the member; an option that [leaves the switcher empty](#children) has no opinion, so the attribute still seeds it, and one set to **Reset to default** sends the switcher to its [default option](#field-switcher-default-option) rather than re-resolving the attribute. A value that isn't among the **Alternatives** is ignored rather than forced: the attribute is set per user and the options are set per dashboard, so the two can drift apart without anyone editing either, and the safe reading of an unusable value is "no opinion" — the control falls back to the default option. ### What the swap preserves @@ -189,15 +189,11 @@ A chart whose query doesn't use the replaced member at all is a different case: A chart with a [period comparison][ref-charts] is a narrower case: the comparison can't follow a member switch, so the chart applies the switch and drops the comparison, saying so in its own notice rather than silently showing a comparison that no longer matches the data. - -A field switcher can't be a [parent control's](#parent) child, and a viewer's choice in one isn't carried in the [shared URL](#sharing-the-current-selection). Both are current limitations rather than deliberate design. - - ## Parent A parent control is a dropdown of options you define. Picking one re-points a whole row of other controls at once — so a viewer makes a single choice instead of adjusting three or four filters by hand. -Unlike the other control types, a parent control targets no member and never touches a chart query directly. It applies values to the controls it *drives* — its **children** — and those children then apply themselves to charts exactly as if the viewer had operated each one. Filters and time granularity switchers can both be children; a [field switcher](#field-switcher) cannot yet, and a parent control cannot be a child of another parent control. +Unlike the other control types, a parent control targets no member and never touches a chart query directly. It applies values to the controls it *drives* — its **children** — and those children then apply themselves to charts exactly as if the viewer had operated each one. Filters, time granularity switchers and [field switchers](#field-switcher) can all be children; a parent control cannot be a child of another parent control. For example, an **Analysis** parent with the options `Retail`, `Wholesale` and `Promo` can set a **Channel** filter, a **Minimum order value** filter, and a **Date range** filter to a different combination for each option. Viewers see one dropdown; you can [hide](#visibility) the children if the individual values aren't worth showing. @@ -215,14 +211,14 @@ Renaming an option later doesn't disturb the values you've mapped to it, so you ### Children -On the **Children** tab, pick a control from **Child control**, then give each of the parent's options a value for it. Each row renders *that child's own control* — a time granularity switcher's row shows its granularity picker, limited to the granularities that switcher allows; a filter's row shows that filter's operator and value inputs. So the values you can offer are exactly the ones a viewer could pick in the child itself. +On the **Children** tab, pick a control from **Child control**, then give each of the parent's options a value for it. Each row renders *that child's own control* — a time granularity switcher's row shows its granularity picker, limited to the granularities that switcher allows; a filter's row shows that filter's operator and value inputs; a field switcher's row shows its member picker, limited to the members that switcher offers — its **Alternatives**, plus the replaced member. So the values you can offer are exactly the ones a viewer could pick in the child itself. Repeat for each control you want the parent to drive. Every option/child pair can be in one of three states: | State | What happens when the viewer picks that option | |---|---| | **A value** | The child is set to that value. | -| **Reset to default** | The child is cleared back to its own default. For a filter that means no filtering on that dimension. Turn on the row's **Reset to default** switch. | +| **Reset to default** | The child is put back to its own default. For a filter that means no filtering on that dimension; for a time granularity switcher, its [default granularity](#default-granularity); for a field switcher, its [default option](#field-switcher-default-option). Turn on the row's **Reset to default** switch. | | **Left empty** | The child is left alone — it keeps whatever value the viewer already had. Use this deliberately when an option shouldn't have an opinion about a particular child. | While the parent's settings are open, the children it drives are highlighted on the canvas, so you can see the scope of the mapping at a glance. @@ -233,7 +229,7 @@ A control can be driven by only one parent control at a time. Mapping a child th ### Mapping status on child controls -Once a dashboard has at least one parent control, every filter and time granularity switcher on it shows a small indicator reporting how it's driven. [Field switchers](#field-switcher) don't, since a parent can't drive one: +Once a dashboard has at least one parent control, every filter, time granularity switcher and [field switcher](#field-switcher) on it shows a small indicator reporting how it's driven: | Status | Meaning | |---|---| @@ -255,7 +251,7 @@ If you never pick an option, the parent opens with nothing selected and the chil The default above is one arrangement for everyone. To give each viewer their own, turn on **User attribute default** in the parent control's settings and pick a [user attribute][ref-user-attributes]. When the dashboard loads, Cube reads that attribute for the current viewer and opens the control on the option it names — and drives the children with it, exactly as if the viewer had picked that option themselves. -This is how you ship one dashboard that opens differently per audience: a **Reporting period** parent whose options are `Month` and `Quarter`, opening on whichever one the viewer's `reporting_period` attribute says, with every filter and time granularity switcher behind it already set to match. +This is how you ship one dashboard that opens differently per audience: a **Reporting period** parent whose options are `Month` and `Quarter`, opening on whichever one the viewer's `reporting_period` attribute says, with every control behind it already set to match. To configure it: @@ -292,7 +288,7 @@ Viewers can still switch to another option unless the control's [visibility](#vi ## Sharing the current selection -On a published dashboard, the values a viewer picks in the controls are reflected in the URL, so the view they are looking at is bookmarkable and shareable. Copy the address bar, send it on, and the recipient opens the dashboard with the same filters and granularities applied. +On a published dashboard, the values a viewer picks in the controls are reflected in the URL, so the view they are looking at is bookmarkable and shareable. Copy the address bar, send it on, and the recipient opens the dashboard with the same filters, granularities and member choices applied. Each control type has its own parameter: @@ -300,20 +296,20 @@ Each control type has its own parameter: |---|---|---| | [Filter](#filter) | `f_.=` | `f_orders.status={"value":"shipped"}` | | [Time granularity switcher](#time-granularity-switcher) | `tg_.=` | `tg_orders.created_at=week` | +| [Field switcher](#field-switcher) | `ms_.=` | `ms_orders.status=users_city` | -The semantic view and dimension are the **internal names** configured on the control — not the display titles you see in the picker. A view shown as `Orders` is usually `orders` in the parameter. Granularities are lowercase and must be one of the switcher's [allowed granularities](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour` for time dimensions that expose them. +The semantic view and member are the **internal names** configured on the control — not the display titles you see in the picker. A view shown as `Orders` is usually `orders` in the parameter. The same goes for the member on the right-hand side of `ms_`: it is the internal name of the member to switch to, and it is a measure rather than a dimension when the switcher's **Field Type** is **Measure**. Granularities are lowercase and must be one of the switcher's [allowed granularities](#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour` for time dimensions that expose them. You can also write these parameters by hand to open a dashboard in a particular state — see [Pre-set dashboard filters and granularities via URL][ref-embed-url-filters] for the embedded case, which uses the same format. What does and doesn't travel in the link: - **Only what the viewer chose.** Values that came from the control's own configuration — a static default, a [default granularity](#default-granularity) — are not written into the URL. Every viewer already gets those from the dashboard itself, and leaving them out means a link stays correct after the dashboard's defaults change. -- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter or a [time granularity switcher](#time-granularity-user-attribute-default) directly, or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes. -- **Filters and granularities together.** Picking both puts both in the link, including when a [parent control](#parent) sets several children at once. A parent control isn't serialized itself — the link carries the values its children ended up with, so the recipient sees the same data while the parent dropdown opens on whatever default it resolves for them, which may not be the option the sharer picked. -- **Not the field switcher, yet.** A [field switcher](#field-switcher) has no parameter of its own, so a viewer's member choice doesn't travel in the link. The recipient opens on the control's [default option](#field-switcher-default-option), or on their own [user attribute](#field-switcher-user-attribute-default) where one is set — and on the charts the sharer was looking at, built on a different member than the sharer saw. +- **Never a personalized default.** A value resolved from a [user attribute](#user-attribute-default) stays out of the link — whether it seeded a filter, a [time granularity switcher](#time-granularity-user-attribute-default) or a [field switcher](#field-switcher-user-attribute-default) directly, or reached one through a [parent control](#parent) opening on the viewer's own option. Sharing a dashboard never pins your attribute value onto the recipient; they see it through their own attributes. +- **Every control's pick, together.** Picking in several controls puts them all in the link, including when a [parent control](#parent) sets several children at once — filters, time granularity switchers and [field switchers](#field-switcher) alike. A parent control isn't serialized itself — the link carries the values its children ended up with, so the recipient sees the same data while the parent dropdown opens on whatever default it resolves for them, which may not be the option the sharer picked. - **Written out on published dashboards only.** Reading these parameters works anywhere, including [embedded][ref-embed-url-filters] dashboards; it's the writing that is published-only. In the dashboard builder the URL is left to the editing session, so changing a control there doesn't rewrite it. -When a dashboard opens with these parameters, they are applied on top of whatever defaults the controls carry. A parameter is ignored when nothing on the dashboard can honor it — there is no matching control for that dimension, or the requested granularity isn't in the switcher's [allowed granularities](#allowed-granularities). +When a dashboard opens with these parameters, they are applied on top of whatever defaults the controls carry. A parameter is ignored when nothing on the dashboard can honor it — there is no matching control for that member, the requested granularity isn't in the switcher's [allowed granularities](#allowed-granularities), or the selected member is one the field switcher doesn't offer — its **Alternatives**, plus the replaced member. ## Visibility diff --git a/docs-mintlify/embedding/iframe/dashboards.mdx b/docs-mintlify/embedding/iframe/dashboards.mdx index 8cdaccff23ccd..7345cc139e771 100644 --- a/docs-mintlify/embedding/iframe/dashboards.mdx +++ b/docs-mintlify/embedding/iframe/dashboards.mdx @@ -68,11 +68,13 @@ URL parameters: |---|---|---| | Filter | `f_.=` | `f_orders_transactions.users_country={"value":"USA"}` | | Time granularity switcher | `tg_.=` | `tg_orders_transactions.created_at=week` | +| Field switcher | `ms_.=` | `ms_orders_transactions.status=users_city` | -The `` and `` must match the internal names (not -display titles) of the semantic view and dimension configured on the widget. For -filters, an omitted filter type defaults to `equals`. Granularities are lowercase -and must be one of the switcher's [allowed +The semantic view and member must match the internal names (not display titles) +configured on the widget — as must the member on the right-hand side of `ms_`, +which is a measure rather than a dimension when the field switcher's **Field +Type** is **Measure**. For filters, an omitted filter type defaults to `equals`. +Granularities are lowercase and must be one of the switcher's [allowed granularities](/docs/explore-analyze/dashboards/widgets/controls#allowed-granularities) — `day`, `week`, `month`, `quarter`, `year`, plus `second`, `minute`, and `hour` for time dimensions that expose them. @@ -88,8 +90,11 @@ before the URL goes anywhere real — pasted as-is into the `src="…"` of the i snippet above, its raw `"` closes the attribute and truncates the URL. This works on both regular and published (embedded) dashboards. A parameter is -applied only if a matching control for that dimension already exists on the -dashboard, and a granularity outside the switcher's allowed list is ignored. +applied only if a matching control for that member already exists on the +dashboard; a granularity outside the switcher's allowed list is ignored, as is a +member the field switcher doesn't offer — its +[**Alternatives**](/docs/explore-analyze/dashboards/widgets/controls#field-switcher), +plus the member it replaces. The reverse direction works on published dashboards: when a viewer changes a control there, the new value is written back into the dashboard's own URL, so the From 71f1191101bb8bbe6ac6a411e61e8adf5f21ec91 Mon Sep 17 00:00:00 2001 From: Gleb Sologub Date: Wed, 2 Sep 2026 14:47:36 +0200 Subject: [PATCH 6/7] docs(dashboards): an export carries every control's selection, not just two (#11738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(dashboards): an export carries every control's selection, not just two The Dashboards page told readers that a PNG/PDF snapshot carries "the filter and time-grain selections you currently have applied in your browser". That was exhaustive when it was written and stopped being so when CUB-4289 landed (cubedevinc/cubejs-enterprise#14633): a field switcher's member now reaches every server-rendered copy too, so the sentence enumerated two of the three things that travel and silently excluded the third. Left behind by the two PRs that covered this on the Controls and embedding pages — different file, so neither touched it, and a reader who only ever opens the Dashboards overview would still have concluded their switcher does not survive an export. Scope is deliberately just this sentence. `memberSwitchers`, the API field for the scheduled case, is not hand-documented here because `filters` and `timeGrains` aren't either — those reach readers through the generated management-API reference, and the new field lands there with the next SDK release. * docs(dashboards): scope the export claim to a download you start yourself Review caught that the sentence was over-broad in a way the page then invites the reader to act on. Three paragraphs down it says the same screenshot mechanism powers PNG/PDF attachments on scheduled notifications — so "the selections you currently have applied in your browser are carried into the export" reads as covering the scheduled case, where there is no browser session at all and the attachment renders whatever the notification itself carries. That over-broadness predates this PR: the original sentence made the same claim about filters and time grains. Fixed here rather than left alone, since this PR is what draws attention to the sentence. Also from review: "time granularity switcher" is what controls.mdx calls the second control, so the list now names all three the same way instead of mixing a thing ("time grains") with two control types; and the paragraph is re-wrapped to the file's ~76 columns. * docs(dashboards): don't send readers to a notification setting that has no UI "Renders the selections configured on the notification" promised something a reader can go and find. They can't: the notification card is delivery channel → recipients → attachment format → AI summary, and notifications.mdx documents only PNG vs PDF about the attachment. The capability is real but API-only, and `memberSwitchers` isn't in the checked-in spec yet — so linking the management API would fail them too, three paragraphs above a link to the page that already would. Also fixes the assumption underneath it: the sentence implied a notification always carries selections. Most don't. With none configured `buildDashboardFilterParams` returns `undefined`, the capture URL is left untouched, and the board renders its own defaults — which is the common case and now the one stated first. --- .../docs/explore-analyze/dashboards/index.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs-mintlify/docs/explore-analyze/dashboards/index.mdx b/docs-mintlify/docs/explore-analyze/dashboards/index.mdx index 2a3cbdec0b95e..704731677fdbf 100644 --- a/docs-mintlify/docs/explore-analyze/dashboards/index.mdx +++ b/docs-mintlify/docs/explore-analyze/dashboards/index.mdx @@ -81,9 +81,12 @@ title. PNG and PDF downloads are server-rendered snapshots — Cube re-opens the dashboard (or, for a single chart, that chart on its own), waits for rendering to finish, and captures the result. This can take up to a couple of minutes -for large dashboards. The filter and time-grain selections you currently have -applied in your browser are carried into the export. (CSV is generated from the -data already loaded in the chart and downloads immediately.) +for large dashboards. The control selections you currently have applied in your +browser are carried into a download you start yourself — filters, time +granularity switchers and [field switchers][ref-controls] alike. A scheduled +notification has no browser session, so its attachment renders the dashboard's +own defaults, unless that notification carries selections of its own. (CSV is +generated from the data already loaded in the chart and downloads immediately.) Downloading the whole dashboard requires **Manage** permission on the workbook that owns it; exporting a single chart requires the **Download data** @@ -94,6 +97,7 @@ after a [scheduled refresh][ref-scheduled-refreshes]. [ref-workbooks]: /docs/explore-analyze/workbooks [ref-widgets]: /docs/explore-analyze/dashboards/widgets +[ref-controls]: /docs/explore-analyze/dashboards/widgets/controls [ref-dimension-links]: /docs/data-modeling/dimensions#links [ref-notifications]: /docs/explore-analyze/notifications [ref-scheduled-refreshes]: /docs/explore-analyze/scheduled-refreshes From 66210e3c6ff068707e44b07e10929956b54c5b3a Mon Sep 17 00:00:00 2001 From: Dmitry Patsura Date: Wed, 2 Sep 2026 15:12:48 +0200 Subject: [PATCH 7/7] feat(client-core): Drop usage of cross-fetch, migrate to fetch API (#11736) --- packages/cubejs-client-core/package.json | 5 ++- .../cubejs-client-core/src/HttpTransport.ts | 1 - packages/cubejs-client-core/src/streaming.ts | 38 ++++++------------- .../test/HttpTransport.test.ts | 32 ++++++++-------- yarn.lock | 14 ------- 5 files changed, 30 insertions(+), 60 deletions(-) diff --git a/packages/cubejs-client-core/package.json b/packages/cubejs-client-core/package.json index 879cc60d3ea99..dfd3bb8e953ea 100644 --- a/packages/cubejs-client-core/package.json +++ b/packages/cubejs-client-core/package.json @@ -1,7 +1,9 @@ { "name": "@cubejs-client/core", "version": "1.7.32", - "engines": {}, + "engines": { + "node": ">=20.0.0" + }, "type": "module", "repository": { "type": "git", @@ -34,7 +36,6 @@ }, "dependencies": { "core-js": "^3.6.5", - "cross-fetch": "^3.0.2", "d3-format": "^3.1.0", "d3-time-format": "^4.1.0", "dayjs": "^1.10.4", diff --git a/packages/cubejs-client-core/src/HttpTransport.ts b/packages/cubejs-client-core/src/HttpTransport.ts index 306daec4cba2c..b0fac22ad2bb2 100644 --- a/packages/cubejs-client-core/src/HttpTransport.ts +++ b/packages/cubejs-client-core/src/HttpTransport.ts @@ -1,4 +1,3 @@ -import fetch from 'cross-fetch'; import 'url-search-params-polyfill'; import { responseChunks } from './streaming.js'; diff --git a/packages/cubejs-client-core/src/streaming.ts b/packages/cubejs-client-core/src/streaming.ts index c63f54ff1641d..71dad13155ee0 100644 --- a/packages/cubejs-client-core/src/streaming.ts +++ b/packages/cubejs-client-core/src/streaming.ts @@ -1,33 +1,17 @@ export async function* responseChunks(res: Response): AsyncIterable { - // eslint-disable-next-line prefer-destructuring - const body: any = res.body; - - if (body && typeof body.getReader === 'function') { - const reader = body.getReader(); // Browser / Node native fetch - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value) yield value; // Uint8Array - } - } finally { - reader.releaseLock?.(); - } - return; + if (!res.body) { + throw new Error('Unsupported response body type for streaming'); } - // Node.js Readable (node-fetch v2 via cross-fetch) - if (body && Symbol.asyncIterator in body) { - for await (const chunk of body as AsyncIterable) { - if (typeof chunk === 'string') { - // Convert string chunks to bytes (rare, but safe) - yield new TextEncoder().encode(chunk); - } else { - yield new Uint8Array(chunk); - } + const reader = res.body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value) yield value; } - return; + } finally { + reader.releaseLock(); } - - throw new Error('Unsupported response body type for streaming'); } diff --git a/packages/cubejs-client-core/test/HttpTransport.test.ts b/packages/cubejs-client-core/test/HttpTransport.test.ts index 26b43b0963079..6d93792d557cd 100644 --- a/packages/cubejs-client-core/test/HttpTransport.test.ts +++ b/packages/cubejs-client-core/test/HttpTransport.test.ts @@ -1,12 +1,7 @@ -/* eslint-disable import/first */ import { vi, MockedFunction } from 'vitest'; -import fetch from 'cross-fetch'; - -vi.mock('cross-fetch'); - import HttpTransport from '../src/HttpTransport.js'; -const mockedFetch = fetch as MockedFunction; +const mockedFetch = vi.fn() as MockedFunction; describe('HttpTransport', () => { const apiUrl = 'http://localhost:3000/cubejs-api/v1'; @@ -33,9 +28,14 @@ describe('HttpTransport', () => { const largeQueryJson = `{"query":{"measures":["Orders.count"],"dimensions":["Users.country"],"filters":[{"member":"Users.id","operator":"equals","values":${JSON.stringify(ids)}}]}}`; beforeAll(() => { + vi.stubGlobal('fetch', mockedFetch); mockedFetch.mockReturnValue(Promise.resolve({ ok: true } as Response)); }); + afterAll(() => { + vi.unstubAllGlobals(); + }); + afterEach(() => { mockedFetch.mockClear(); }); @@ -47,8 +47,8 @@ describe('HttpTransport', () => { }); const req = transport.request('load', { query }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load?query=${queryUrlEncoded}`, { + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load?query=${queryUrlEncoded}`, { method: 'GET', headers: { Authorization: 'token', @@ -69,8 +69,8 @@ describe('HttpTransport', () => { }); const req = transport.request('meta', { extraParams }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${apiUrl}/meta?extraParams=${serializedExtraParams}`, { + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/meta?extraParams=${serializedExtraParams}`, { method: 'GET', headers: { Authorization: 'token', @@ -87,7 +87,7 @@ describe('HttpTransport', () => { }); const req = transport.request('meta', { signal: undefined, baseRequestId: undefined }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(mockedFetch.mock.calls[0]?.[0]).toBe(`${apiUrl}/meta`); }); @@ -100,7 +100,7 @@ describe('HttpTransport', () => { }); const req = transport.request('meta', { signal: undefined, baseRequestId: undefined, onlyViews: true }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledTimes(1); expect(mockedFetch.mock.calls[0]?.[0]).toBe(`${apiUrl}/meta?onlyViews=true`); }); @@ -112,8 +112,8 @@ describe('HttpTransport', () => { }); const req = transport.request('load', { query }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, { + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load`, { method: 'POST', headers: { Authorization: 'token', @@ -130,8 +130,8 @@ describe('HttpTransport', () => { }); const req = transport.request('load', { query: LargeQuery }); await req.subscribe(() => { console.log('subscribe cb'); }); - expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith(`${apiUrl}/load`, { + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith(`${apiUrl}/load`, { method: 'POST', headers: { Authorization: 'token', diff --git a/yarn.lock b/yarn.lock index 08be06a73eb37..7640f218fbe07 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11658,13 +11658,6 @@ cron-validator@^1.2.1: resolved "https://registry.yarnpkg.com/cron-validator/-/cron-validator-1.2.1.tgz#0f0de2de36d231a6ace0e43ffc6c0564fe6edf1a" integrity sha512-RqdpGSokGFICPc8qAkT38aXqZLLanXghQTK2q7a2x2FabSwDd2ARrazd5ElEWAXzToUcMG4cZIwDH+5RM0q1mA== -cross-fetch@^3.0.2: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57" @@ -18382,13 +18375,6 @@ node-fetch@2, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.6, node-fetc dependencies: whatwg-url "^5.0.0" -node-fetch@2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - node-fetch@^1.0.1: version "1.7.3" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"