diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 127c6a669..2fd4cd490 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -12,17 +12,6 @@ env: NEXT_ASSETS_PATH: ${{ github.workspace }}/frontend/dist jobs: - community-ui: - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: '24' - - name: Test community UI overlay - run: node --test scripts/test-community-ui.mjs - clippy: runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 45 diff --git a/Cargo.lock b/Cargo.lock index 6418b05aa..386aced06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1494,16 +1494,6 @@ dependencies = [ "thiserror 2.0.17", ] -[[package]] -name = "cargo_toml" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml", -] - [[package]] name = "cc" version = "1.2.51" @@ -3596,7 +3586,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.4", + "webpki-roots", ] [[package]] @@ -4738,7 +4728,6 @@ dependencies = [ "base64 0.22.1", "byteorder", "bytes", - "cargo_toml", "chrono", "chrono-humanize", "clap", @@ -4798,7 +4787,6 @@ dependencies = [ "serde", "serde_json", "serde_repr", - "sha1_smol", "sha2 0.10.9", "static-files 0.2.5", "sysinfo 0.34.2", @@ -4820,12 +4808,10 @@ dependencies = [ "tracing-subscriber", "ulid", "uptime_lib", - "ureq", "url", "uuid", "vergen-gitcl", "xxhash-rust", - "zip", "zstd", ] @@ -5622,7 +5608,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.4", + "webpki-roots", ] [[package]] @@ -5971,15 +5957,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6014,12 +5991,6 @@ dependencies = [ "digest 0.11.2", ] -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - [[package]] name = "sha2" version = "0.10.9" @@ -6581,21 +6552,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.9.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -6626,12 +6582,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tonic" version = "0.14.2" @@ -6863,12 +6813,6 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" -[[package]] -name = "typed-path" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" - [[package]] name = "typenum" version = "1.20.0" @@ -6945,22 +6889,6 @@ dependencies = [ "windows 0.57.0", ] -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.7" @@ -7309,15 +7237,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.4", -] - [[package]] name = "webpki-roots" version = "1.0.4" @@ -7948,20 +7867,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "zip" -version = "8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" -dependencies = [ - "crc32fast", - "flate2", - "indexmap", - "memchr", - "typed-path", - "zopfli", -] - [[package]] name = "zlib-rs" version = "0.6.3" @@ -7974,18 +7879,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f4a4e8e9dc5c62d159f04fcdbe07f4c3fb710415aab4754bf11505501e3251d" -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index b666b79c7..d2cc8d8d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,22 +207,13 @@ tikv-jemallocator = { version = "0.7.0", features = [ tikv-jemalloc-ctl = { version = "0.7.0", features = ["stats"] } [build-dependencies] -regex = "1" -serde = { version = "1.0.228", features = ["derive"] } -serde_json = "1.0.150" -sha2 = "0.10.8" -cargo_toml = "0.22.3" -sha1_smol = { version = "1.0", features = ["std"] } static-files = "0.2" -ureq = "2.12" -url = "2.5" vergen-gitcl = { version = "9.1.0", features = [ "build", "cargo", "rustc", "si", ] } -zip = { version = "8.6.0", default-features = false, features = ["deflate"] } anyhow = "1.0" [target.'cfg(target_os = "linux")'.dependencies] @@ -233,10 +224,6 @@ rstest = "0.26.1" arrow = "59.2.0" temp-dir = "0.1.14" -[package.metadata.parseable_ui] -assets-url = "https://parseableprismbuild.blob.core.windows.net/parseable-prism-build/v3.2.5/build.zip" -assets-sha1 = "cbaf98f0431844677d30f519b3da941599eb2e0a" - [features] debug = [] hotpath = [ diff --git a/Dockerfile.debug b/Dockerfile.debug index 62f14b93b..28a9b9516 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -34,17 +34,17 @@ WORKDIR /parseable # Cache dependencies COPY Cargo.toml Cargo.lock build.rs ./ COPY build_support ./build_support -COPY scripts/community-ui-overlay.json scripts/community-promql-capabilities.js ./scripts/ -RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build && rm -rf src -# Embed frontend assets after compiling dependencies so UI edits reuse them. +# The build script requires native assets, including during dependency warmup. COPY --from=frontend /frontend/dist /parseable/frontend/dist ENV NEXT_ASSETS_PATH=/parseable/frontend/dist +RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build && rm -rf src # Build the actual binary COPY src ./src COPY resources ./resources -RUN cargo build +# Invalidate the dummy crate even when COPY preserves older source timestamps. +RUN touch src/main.rs src/lib.rs && cargo build # final stage FROM docker.io/debian:bookworm-slim diff --git a/Dockerfile.kafka b/Dockerfile.kafka index fd1f323d7..93c04e297 100644 --- a/Dockerfile.kafka +++ b/Dockerfile.kafka @@ -43,7 +43,10 @@ RUN apt-get update && \ WORKDIR /parseable COPY Cargo.toml Cargo.lock build.rs ./ COPY build_support ./build_support -COPY scripts/community-ui-overlay.json scripts/community-promql-capabilities.js ./scripts/ + +# The build script requires native assets, including during dependency warmup. +COPY --from=frontend /frontend/dist /parseable/frontend/dist +ENV NEXT_ASSETS_PATH=/parseable/frontend/dist # Fix librdkafka CMakeLists.txt before building RUN mkdir -p src && echo "fn main() {}" > src/main.rs && \ @@ -68,16 +71,13 @@ RUN mkdir -p src && echo "fn main() {}" > src/main.rs && \ RUN cargo build --release --features kafka && \ rm -rf src -# Embed frontend assets after compiling dependencies so UI edits reuse them. -COPY --from=frontend /frontend/dist /parseable/frontend/dist -ENV NEXT_ASSETS_PATH=/parseable/frontend/dist - # Copy the actual source code COPY src ./src COPY resources ./resources # Build the actual binary with kafka feature -RUN cargo build --release --features kafka +# Invalidate the dummy crate even when COPY preserves older source timestamps. +RUN touch src/main.rs src/lib.rs && cargo build --release --features kafka # final stage FROM gcr.io/distroless/cc-debian12:latest diff --git a/README.md b/README.md index c64fdb36c..5d0dce79c 100644 --- a/README.md +++ b/README.md @@ -114,23 +114,38 @@ gh attestation verify PATH/TO/YOUR/PARSEABLE/ARTIFACT-BINARY -R parseablehq/pars ### Editable frontend -An initial React/TypeScript frontend and reusable component library live in -[`frontend/`](frontend/README.md), with Logs Explore, SQL, datasets, local -dashboards, Storybook, and browser tests. Billing and payment collection are -excluded. - -Parseable serves it at `/`, and links to its earlier `/next/` mount redirect -there. For one release, `P_UI_ROOT=classic` restores the classic UI at `/` and -mounts the new frontend beside it at `/next/`, sharing the same session. Docker -images and build/release workflows build it with Node 24 and embed -`frontend/dist` through `NEXT_ASSETS_PATH`. To build locally from the -repository root: +The React/TypeScript frontend and reusable component library live in +[`frontend/`](frontend/README.md), with Logs Explore, SQL, datasets, dashboards, +Storybook, and browser tests. Billing and payment collection are excluded. + +Parseable embeds only this native frontend and serves it at `/`. Links to the +earlier `/next/` mount redirect to the same path at the root. Classic Prism assets +are no longer downloaded or embedded; `P_UI_ROOT=classic` is rejected with +migration instructions. Docker images and build/release workflows build native +assets with Node 24 and embed `frontend/dist` through `NEXT_ASSETS_PATH`. +To build locally from the repository root: ```sh (cd frontend && npm ci && npm run build) NEXT_ASSETS_PATH="$PWD/frontend/dist" cargo build --release ``` -Then open `/`. See the -[frontend README](frontend/README.md#build-and-embed-in-parseable) for build -details, PowerShell instructions, and the classic UI's rollback setting. +Missing native assets fail the build. For backend-only debug development, explicitly +use `P_BUILD_NO_UI=1 cargo run -- local-store` with `NEXT_ASSETS_PATH` unset; UI +requests then explain that the UI is disabled. This mode cannot be used for release +builds. `/api/v1/about` reports `native-` or `disabled` as the UI +version, alongside the server commit/build metadata. + +To run this checkout in Docker: + +```sh +docker compose -f compose.yaml -f compose.source.yaml up -d --build +``` + +`docker compose up` alone uses the prebuilt upstream image from `compose.yaml`; +it does not build this checkout and can still show the old UI. The source override +uses the existing named data/staging volumes. + +See the [native UI retirement notes](docs/native-ui.md) and +[frontend build instructions](frontend/README.md#build-and-embed-in-parseable) +for compatibility, backend development and PowerShell details. diff --git a/build.rs b/build.rs index 6d22bb874..d7184211e 100644 --- a/build.rs +++ b/build.rs @@ -16,9 +16,6 @@ * */ -#[path = "build_support/community_ui.rs"] -mod community_ui; - use anyhow::Result; use vergen_gitcl::{ BuildBuilder, CargoBuilder, Emitter, GitclBuilder, RustcBuilder, SysinfoBuilder, @@ -26,7 +23,6 @@ use vergen_gitcl::{ pub fn main() -> Result<()> { ui::setup()?; - ui::setup_next()?; // Init vergen Emitter::default() @@ -40,159 +36,51 @@ pub fn main() -> Result<()> { Ok(()) } -mod ui { - - use std::fs::{self, OpenOptions, create_dir_all}; - use std::io::{self, Cursor, Read, Write}; - use std::path::{Path, PathBuf}; - use std::{env, panic}; +#[path = "build_support/native_ui.rs"] +mod native_ui; - use cargo_toml::Manifest; - use sha1_smol::Sha1; +mod ui { use static_files::resource_dir; - use ureq::get as get_from_url; - - const CARGO_MANIFEST_DIR: &str = "CARGO_MANIFEST_DIR"; - const OUT_DIR: &str = "OUT_DIR"; - const LOCAL_ASSETS_PATH: &str = "LOCAL_ASSETS_PATH"; - const NEXT_ASSETS_PATH: &str = "NEXT_ASSETS_PATH"; - - fn build_resource_from(local_path: impl AsRef) -> io::Result<()> { - let local_path = local_path.as_ref(); - if local_path.exists() { - resource_dir(local_path).build() - } else { - Err(io::Error::new( - io::ErrorKind::NotFound, - "Local UI directory not found!", - )) - } - } + use std::{env, fs, io, path::PathBuf}; - /// Embeds the native frontend, served at `/` or, with `P_UI_ROOT=classic`, - /// under `/next` beside the classic UI. Without one, `generate_next` returns - /// no files and the classic UI serves `/`. - pub fn setup_next() -> io::Result<()> { - println!("cargo:rerun-if-env-changed={NEXT_ASSETS_PATH}"); - let out_dir = PathBuf::from(env::var(OUT_DIR).unwrap()); - let generated = out_dir.join("next_generated.rs"); - let Ok(path) = env::var(NEXT_ASSETS_PATH) else { - return fs::write( - generated, - "pub fn generate_next() -> ::std::collections::HashMap<&'static str, ::static_files::Resource> {\n ::std::collections::HashMap::new()\n}\n", - ); - }; - let path = PathBuf::from(path); - let Ok(index) = fs::read_to_string(path.join("index.html")) else { - panic!("Directory specified in NEXT_ASSETS_PATH has no index.html") - }; - // The server points this base at whichever mount serves the build. - if !index.contains(r#"; rebuild it with `npm run build`" - ) - } - println!("cargo:rerun-if-changed={}", path.display()); - let mut next = resource_dir(path); - next.with_generated_filename(generated) - .with_generated_fn("generate_next"); - next.build() - } + use crate::native_ui; pub fn setup() -> io::Result<()> { - println!("cargo:rerun-if-env-changed={LOCAL_ASSETS_PATH}"); + for name in ["NEXT_ASSETS_PATH", "P_BUILD_NO_UI", "LOCAL_ASSETS_PATH"] { + println!("cargo:rerun-if-env-changed={name}"); + } println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-changed=Cargo.toml"); - let cargo_manifest_dir = PathBuf::from(env::var(CARGO_MANIFEST_DIR).unwrap()); - let cargo_toml = cargo_manifest_dir.join("Cargo.toml"); - let out_dir = PathBuf::from(env::var(OUT_DIR).unwrap()); - let parseable_ui_path = out_dir.join("ui"); - let checksum_path = out_dir.join("parseable_ui.sha1"); - - let manifest = Manifest::from_path(cargo_toml).unwrap(); - - let manifest = manifest - .package - .expect("package not specified in Cargo.toml") - .metadata - .expect("no metadata specified in Cargo.toml"); - - let metadata = manifest - .get("parseable_ui") - .expect("Parseable UI Metadata not defined correctly"); - - // try fetching frontend path from env var - let local_assets_path: Option = - env::var(LOCAL_ASSETS_PATH).ok().map(PathBuf::from); - - // If local build of ui is to be used - if let Some(ref path) = local_assets_path { - if path.exists() { - println!("cargo:rerun-if-changed={}", path.to_str().unwrap()); - println!("cargo:rustc-env=UI_VERSION=local"); - build_resource_from(path).unwrap(); - return Ok(()); - } else { - panic!("Directory specified in LOCAL_ASSETS_PATH is not found") - } + println!("cargo:rerun-if-changed=build_support/native_ui.rs"); + if env::var_os("LOCAL_ASSETS_PATH").is_some() { + return Err(io::Error::other( + "LOCAL_ASSETS_PATH was removed with the classic UI; build frontend/ and set NEXT_ASSETS_PATH to its dist directory", + )); } - - // Emit this on every upstream build, including a cached asset build. - let url = metadata["assets-url"].as_str().unwrap(); - let asset_url = url::Url::parse(url).expect("valid url"); - let ui_version = asset_url - .path_segments() - .expect("has segments") - .find(|v| v.starts_with('v')) - .expect("version segment"); - println!("cargo:rustc-env=UI_VERSION={ui_version}"); - - let cached = checksum_path.exists() - && parseable_ui_path.join("dist").exists() - && fs::read_to_string(&checksum_path)? == metadata["assets-sha1"].as_str().unwrap(); - if !cached { - // If there is no UI in the target directory or checksum check failed - // then we downlaod the UI from given url in cargo.toml metadata - // See https://docs.rs/ureq/2.5.0/ureq/struct.Response.html#method.into_reader - let parseable_ui_bytes = get_from_url(url) - .call() - .map(|data| { - let mut buf: Vec = Vec::new(); - data.into_reader().read_to_end(&mut buf).unwrap(); - buf - }) - .expect("Failed to get resource from {url}"); - - let checksum = Sha1::from(&parseable_ui_bytes).hexdigest(); - - assert_eq!( - metadata["assets-sha1"].as_str().unwrap(), - checksum, - "Downloaded parseable UI shasum differs from the one specified in the Cargo.toml" + let assets = env::var_os("NEXT_ASSETS_PATH").map(PathBuf::from); + let disabled = native_ui::no_ui( + env::var("P_BUILD_NO_UI").ok().as_deref(), + env::var("PROFILE").as_deref() == Ok("debug"), + assets.is_some(), + )?; + if disabled { + println!( + "cargo:warning=Building without a UI for backend development (P_BUILD_NO_UI=1)" + ); + println!("cargo:rustc-env=UI_VERSION=disabled"); + return fs::write( + PathBuf::from(env::var_os("OUT_DIR").unwrap()).join("generated.rs"), + "pub fn generate() -> ::std::collections::HashMap<&'static str, ::static_files::Resource> { ::std::collections::HashMap::new() }\n", ); - - create_dir_all(&parseable_ui_path)?; - let mut zip = zip::read::ZipArchive::new(Cursor::new(&parseable_ui_bytes))?; - zip.extract(&parseable_ui_path)?; - - let mut file = OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(checksum_path)?; - - file.write_all(checksum.as_bytes())?; - file.flush()?; } - let community_path = out_dir.join("community-ui"); - crate::community_ui::prepare( - &cargo_manifest_dir, - &parseable_ui_path.join("dist"), - &community_path, - ui_version, - )?; - resource_dir(&community_path).build()?; - - Ok(()) + let path = assets.ok_or_else(|| io::Error::other( + "Native UI assets are required. Run `cd frontend && npm ci && npm run build`, then set NEXT_ASSETS_PATH to frontend/dist. For backend-only debug development, set P_BUILD_NO_UI=1.", + ))?; + println!("cargo:rerun-if-changed={}", path.display()); + native_ui::validate(&path)?; + println!( + "cargo:rustc-env=UI_VERSION=native-{}", + env!("CARGO_PKG_VERSION") + ); + resource_dir(path).build() } } diff --git a/build_support/community_ui.rs b/build_support/community_ui.rs deleted file mode 100644 index 34ab5cba0..000000000 --- a/build_support/community_ui.rs +++ /dev/null @@ -1,574 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-or-later -//! Reproducible, fail-closed overlay for the pinned Prism distribution. -use regex::Regex; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::{ - collections::{BTreeMap, BTreeSet}, - fs, io, - path::{Component, Path, PathBuf}, - sync::LazyLock, -}; - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct Manifest { - version: String, - files: Vec, - additions: Vec, - cache_bust: CacheBust, -} -#[derive(Deserialize)] -struct CacheBust { - mode: String, -} -#[derive(Deserialize)] -struct File { - path: String, - sha256: String, - replacements: Vec, -} -#[derive(Deserialize)] -struct Replacement { - from: String, - to: String, - count: usize, -} -#[derive(Deserialize)] -struct Addition { - path: String, - source: String, -} -fn invalid(message: impl Into) -> io::Error { - io::Error::other(message.into()) -} -fn relative(root: &Path, path: &str) -> io::Result { - let p = Path::new(path); - if p.as_os_str().is_empty() || p.components().any(|c| !matches!(c, Component::Normal(_))) { - return Err(invalid(format!("invalid overlay path: {path}"))); - } - Ok(root.join(p)) -} -fn files(dir: &Path, output: &mut Vec) -> io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let kind = entry.file_type()?; - if kind.is_dir() { - files(&entry.path(), output)?; - } else if kind.is_file() { - output.push(entry.path()); - } else { - return Err(invalid("UI distribution contains a non-regular file")); - } - } - Ok(()) -} -fn module_paths(source: &Path) -> io::Result> { - let mut paths = Vec::new(); - files(source, &mut paths)?; - let mut modules = BTreeMap::new(); - for path in paths { - if path.extension().is_some_and(|e| e == "js" || e == "html") { - let name = path.strip_prefix(source).unwrap().components(); - let name = name - .map(|c| c.as_os_str().to_string_lossy().into_owned()) - .collect::>() - .join("/"); - modules.insert( - name, - String::from_utf8_lossy(&fs::read(&path)?).into_owned(), - ); - } - } - Ok(modules) -} -/// Resolves a module specifier to a distribution-relative path. Relative -/// specifiers resolve against the importer; others against the deployment base. -fn resolve_reference(from: &str, specifier: &str) -> Option { - let external = specifier.split_once(':').is_some_and(|(scheme, _)| { - scheme.starts_with(|c: char| c.is_ascii_alphabetic()) - && scheme - .chars() - .all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c)) - }); - if external || specifier.starts_with("//") { - return None; - } - let mut segments: Vec<&str> = Vec::new(); - if specifier.starts_with("./") || specifier.starts_with("../") { - segments.extend(from.split('/')); - segments.pop(); - } - for segment in specifier.split('/') { - match segment { - "" | "." => {} - ".." => { - segments.pop()?; - } - _ => segments.push(segment), - } - } - Some(segments.join("/")) -} -fn quoted_module(inner: &str) -> String { - let specifier = r#"[^"'`\s]+?\.js"#; - inner.replace( - "{}", - &format!(r#"(?:"({specifier})"|'({specifier})'|`({specifier})`)"#), - ) -} -static HTML_REFERENCE: LazyLock = LazyLock::new(|| { - Regex::new( - r#"<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*(?:"([^"']+?\.js)"|'([^"']+?\.js)')"#, - ) - .unwrap() -}); -static IMPORT: LazyLock = LazyLock::new(|| { - Regex::new("ed_module( - r"\b(?:from|import)\s*{}|\bimport\s*\(\s*{}\s*\)", - )) - .unwrap() -}); -static PRELOAD_MAP: LazyLock = - LazyLock::new(|| Regex::new(r"\b__vite__mapDeps\s*=[^\[]*\[([^\]]*)\]").unwrap()); -static PRELOAD_ENTRY: LazyLock = LazyLock::new(|| Regex::new("ed_module("{}")).unwrap()); -fn first_captures<'t>(pattern: &Regex, text: &'t str) -> impl Iterator { - pattern - .captures_iter(text) - .filter_map(|c| c.iter().skip(1).flatten().next().map(|m| m.as_str())) -} -/// Module references that make a JavaScript file reachable from index.html: -/// HTML entry/modulepreload tags, static `import`/`export ... from`, dynamic -/// `import()`, and the Vite `__vite__mapDeps` preload list. Mirrors -/// scripts/prepare-community-ui.mjs. -fn module_references(from: &str, text: &str) -> Vec { - let mut specifiers: Vec = Vec::new(); - if from.ends_with(".html") { - specifiers.extend(first_captures(&HTML_REFERENCE, text).map(str::to_owned)); - } else { - specifiers.extend(first_captures(&IMPORT, text).map(str::to_owned)); - // Vite preload paths are relative to the deployment base, not the importer. - for list in PRELOAD_MAP.captures_iter(text) { - let list = list.get(1).unwrap().as_str(); - specifiers.extend(first_captures(&PRELOAD_ENTRY, list).map(|s| format!("/{s}"))); - } - } - specifiers - .iter() - .filter_map(|s| resolve_reference(from, s)) - .collect() -} -fn reachable_modules(modules: &BTreeMap) -> BTreeSet { - let mut reachable = BTreeSet::from(["index.html".to_owned()]); - let mut pending = vec!["index.html".to_owned()]; - while let Some(from) = pending.pop() { - let text = modules.get(&from).map_or("", String::as_str); - for path in module_references(&from, text) { - if modules.contains_key(&path) && reachable.insert(path.clone()) { - pending.push(path); - } - } - } - reachable -} -/// A Prism upgrade can leave stale chunks from older builds in the -/// distribution; patches to them verify but never run. Every patched file must -/// be part of the stock module graph, every addition must be loaded by the -/// patched graph, and patches must not import modules the stock UI does not -/// load (for example, a stale chunk named in an import specifier). -fn verify_reachability( - version: &str, - stock: &BTreeMap, - patched: &BTreeMap, - patched_paths: &[&str], - addition_paths: &[&str], -) -> io::Result<()> { - let before = reachable_modules(stock); - let after = reachable_modules(patched); - let unreachable: Vec<_> = patched_paths - .iter() - .filter(|p| !before.contains(**p)) - .chain(addition_paths.iter().filter(|p| !after.contains(**p))) - .copied() - .collect(); - if !unreachable.is_empty() { - return Err(invalid(format!( - "{version}: overlay files not reachable from index.html: {}", - unreachable.join(", ") - ))); - } - let foreign: Vec<_> = after - .iter() - .filter(|p| !before.contains(*p) && !addition_paths.contains(&p.as_str())) - .map(String::as_str) - .collect(); - if !foreign.is_empty() { - return Err(invalid(format!( - "{version}: overlay imports modules outside the Prism UI graph: {}", - foreign.join(", ") - ))); - } - Ok(()) -} -/// `prism_version` is the pinned release from Cargo.toml (for example -/// `v3.2.5`); the manifest must have been derived from that release. -pub fn prepare( - root: &Path, - source: &Path, - destination: &Path, - prism_version: &str, -) -> io::Result<()> { - let manifest_path = root.join("scripts/community-ui-overlay.json"); - println!("cargo:rerun-if-changed={}", manifest_path.display()); - println!("cargo:rerun-if-changed=build_support/community_ui.rs"); - let raw = fs::read(manifest_path)?; - let manifest: Manifest = serde_json::from_slice(&raw).map_err(invalid_json)?; - if manifest.cache_bust.mode != "javascript-filenames" { - return Err(invalid("unsupported UI cache busting mode")); - } - if !manifest - .version - .starts_with(&format!("prism-{prism_version}-")) - { - return Err(invalid(format!( - "{}: overlay does not match the pinned Prism UI {prism_version}", - manifest.version - ))); - } - let mut identity = Sha256::new(); - identity.update(&raw); - let mut updates = BTreeMap::new(); - for file in &manifest.files { - let input = fs::read(relative(source, &file.path)?)?; - if format!("{:x}", Sha256::digest(&input)) != file.sha256 { - return Err(invalid(format!( - "{}: SHA256 mismatch for {}", - manifest.version, file.path - ))); - } - let mut text = String::from_utf8(input).map_err(|e| invalid(e.to_string()))?; - for r in &file.replacements { - if r.from.is_empty() || text.matches(&r.from).count() != r.count { - return Err(invalid(format!( - "overlay replacement count mismatch in {}", - file.path - ))); - } - text = text.replace(&r.from, &r.to); - } - updates.insert(file.path.clone(), text.into_bytes()); - } - for addition in &manifest.additions { - let path = relative(root, &addition.source)?; - println!("cargo:rerun-if-changed={}", path.display()); - let bytes = fs::read(path)?; - identity.update(&bytes); - if relative(source, &addition.path)?.exists() - || updates.insert(addition.path.clone(), bytes).is_some() - { - return Err(invalid("overlay addition collides with an existing file")); - } - } - let stock = module_paths(source)?; - let mut patched = stock.clone(); - for (path, bytes) in &updates { - patched.insert(path.clone(), String::from_utf8_lossy(bytes).into_owned()); - } - let patched_paths: Vec<_> = manifest.files.iter().map(|f| f.path.as_str()).collect(); - let addition_paths: Vec<_> = manifest.additions.iter().map(|a| a.path.as_str()).collect(); - verify_reachability( - &manifest.version, - &stock, - &patched, - &patched_paths, - &addition_paths, - )?; - // Always copy pristine inputs; never reapply transformations to patched files. - if destination.exists() { - fs::remove_dir_all(destination)?; - } - fs::create_dir_all(destination)?; - let mut inputs = Vec::new(); - files(source, &mut inputs)?; - for input in inputs { - let target = destination.join(input.strip_prefix(source).unwrap()); - fs::create_dir_all(target.parent().unwrap())?; - fs::copy(input, target)?; - } - for (path, bytes) in updates { - let target = relative(destination, &path)?; - fs::create_dir_all(target.parent().unwrap())?; - fs::write(target, bytes)?; - } - { - let digest = format!("{:x}", identity.finalize()); - let mut output = Vec::new(); - files(destination, &mut output)?; - let names: BTreeMap = output - .iter() - .filter(|p| p.extension().is_some_and(|e| e == "js")) - .map(|p| { - let name = p.file_name().unwrap().to_string_lossy().into_owned(); - let new = format!( - "{}-community-{}.js", - name.trim_end_matches(".js"), - &digest[..12] - ); - (name, new) - }) - .collect(); - let tokens = Regex::new(r"[A-Za-z0-9_.-]+\.js").unwrap(); - for path in output { - let extension = path.extension().and_then(|e| e.to_str()); - if !matches!(extension, Some("js" | "html")) { - continue; - } - let original = fs::read_to_string(&path)?; - let text = tokens.replace_all(&original, |captures: ®ex::Captures<'_>| { - names - .get(&captures[0]) - .cloned() - .unwrap_or_else(|| captures[0].to_owned()) - }); - if extension == Some("js") { - // Only the renamed graph ships. Stale tabs must reload (see - // docs/oidc.md); keeping originals would double the embedded - // UI and let old tabs mix unpatched chunks into the new graph. - let new = &names[path.file_name().unwrap().to_str().unwrap()]; - fs::write(path.with_file_name(new), text.as_bytes())?; - fs::remove_file(path)?; - } else { - fs::write(path, text.as_bytes())?; - } - } - } - Ok(()) -} -fn invalid_json(error: serde_json::Error) -> io::Error { - invalid(error.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const ENTRY: &str = r#"import { v } from "./chunk-a.js"; export const deps = ["assets/chunk-a.js", "assets/extra.js"];"#; - const CHUNK: &str = "export const v = 1;"; - const STALE: &str = "export const v = 1; // left over from an older Prism build"; - - fn sha(text: &str) -> String { - format!("{:x}", Sha256::digest(text)) - } - - fn write(root: &Path, path: &str, text: &str) { - let path = root.join(path); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - fs::write(path, text).unwrap(); - } - - /// A stock distribution whose graph is index.html -> index-main.js -> - /// chunk-a.js, plus an unreferenced stale chunk; `files` is the manifest's - /// file list. The default overlay patches chunk-a.js to load the addition. - fn fixture_with(version: &str, files: serde_json::Value) -> tempfile::TempDir { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path(); - write( - root, - "dist/index.html", - r#""#, - ); - write(root, "dist/assets/index-main.js", ENTRY); - write(root, "dist/assets/chunk-a.js", CHUNK); - write(root, "dist/assets/stale.js", STALE); - write( - root, - "dist/assets/style.css", - "body { content: 'chunk-a.js'; }", - ); - write(root, "scripts/extra.js", "export const extra = true;"); - let manifest = serde_json::json!({ - "version": version, - "files": files, - "additions": [{"path": "assets/extra.js", "source": "scripts/extra.js"}], - "cacheBust": {"mode": "javascript-filenames"}, - }); - write( - root, - "scripts/community-ui-overlay.json", - &manifest.to_string(), - ); - dir - } - - fn patch(path: &str, sha256: &str, import: &str) -> serde_json::Value { - serde_json::json!([{ - "path": path, - "sha256": sha256, - "replacements": [ - {"from": "v = 1", "to": "v = 2", "count": 1}, - {"from": "export const", "to": format!("import \"{import}\";export const"), "count": 1}, - ], - }]) - } - - fn fixture(chunk_sha: &str) -> tempfile::TempDir { - fixture_with( - "prism-v1.0.0-test", - patch("assets/chunk-a.js", chunk_sha, "./extra.js"), - ) - } - - fn run(dir: &tempfile::TempDir) -> io::Result<()> { - let root = dir.path(); - prepare(root, &root.join("dist"), &root.join("out"), "v1.0.0") - } - - fn js_files(dir: &Path) -> Vec { - let mut output = Vec::new(); - files(dir, &mut output).unwrap(); - let mut names: Vec<_> = output - .iter() - .filter(|p| p.extension().is_some_and(|e| e == "js")) - .map(|p| p.file_name().unwrap().to_string_lossy().into_owned()) - .collect(); - names.sort(); - names - } - - #[test] - fn ships_only_the_patched_renamed_graph() { - let dir = fixture(&sha(CHUNK)); - let (root, out) = (dir.path(), dir.path().join("out")); - run(&dir).unwrap(); - - let names = js_files(&out); - assert_eq!( - names.len(), - 4, - "every JS asset ships exactly once: {names:?}" - ); - assert!(names.iter().all(|name| name.contains("-community-"))); - let renamed = |stem: &str| { - names - .iter() - .find(|name| name.starts_with(&format!("{stem}-community-"))) - .unwrap() - .clone() - }; - let (entry, chunk, extra) = (renamed("index-main"), renamed("chunk-a"), renamed("extra")); - - let html = fs::read_to_string(out.join("index.html")).unwrap(); - assert!(html.contains(&format!("/assets/{entry}"))); - let entry_text = fs::read_to_string(out.join("assets").join(&entry)).unwrap(); - assert!(entry_text.contains(&format!("./{chunk}"))); - assert!(entry_text.contains(&format!("assets/{chunk}"))); - assert!(entry_text.contains(&format!("assets/{extra}"))); - let chunk_text = fs::read_to_string(out.join("assets").join(&chunk)).unwrap(); - assert_eq!( - chunk_text, - format!("import \"./{extra}\";export const v = 2;") - ); - // Non-JS assets are copied verbatim. - let css = fs::read_to_string(out.join("assets/style.css")).unwrap(); - assert_eq!(css, "body { content: 'chunk-a.js'; }"); - - // Rebuilding from pristine inputs is deterministic. - prepare(root, &root.join("dist"), &out, "v1.0.0").unwrap(); - assert_eq!(js_files(&out), names); - } - - #[test] - fn refuses_unpinned_inputs() { - let dir = fixture(&"0".repeat(64)); - let error = run(&dir).unwrap_err(); - assert!(error.to_string().contains("SHA256 mismatch")); - assert!(!dir.path().join("out").exists()); - } - - #[test] - fn refuses_a_manifest_for_another_prism_release() { - let dir = fixture(&sha(CHUNK)); - let root = dir.path(); - let error = prepare(root, &root.join("dist"), &root.join("out"), "v1.0.1").unwrap_err(); - assert!( - error - .to_string() - .contains("does not match the pinned Prism UI v1.0.1") - ); - assert!(!root.join("out").exists()); - } - - #[test] - fn refuses_patches_to_chunks_the_ui_never_loads() { - // A stale chunk verifies (hash and counts match) but is unreachable, so - // neither it nor the addition it imports would ever run. - let dir = fixture_with( - "prism-v1.0.0-test", - patch("assets/stale.js", &sha(STALE), "./extra.js"), - ); - let error = run(&dir).unwrap_err().to_string(); - assert!( - error.contains("not reachable from index.html: assets/stale.js, assets/extra.js"), - "{error}" - ); - assert!(!dir.path().join("out").exists()); - } - - #[test] - fn refuses_patches_that_import_modules_outside_the_graph() { - let dir = fixture_with( - "prism-v1.0.0-test", - serde_json::json!([{ - "path": "assets/chunk-a.js", - "sha256": sha(CHUNK), - "replacements": [{"from": "export", "to": "import \"./extra.js\";import \"./stale.js\";export", "count": 1}], - }]), - ); - let error = run(&dir).unwrap_err().to_string(); - assert!( - error.contains("outside the Prism UI graph: assets/stale.js"), - "{error}" - ); - } - - #[test] - fn follows_html_static_dynamic_and_preload_references() { - let modules: BTreeMap = [ - ( - "index.html", - r#""#, - ), - ( - "assets/index.js", - r#"const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Page.js","assets/preloaded.js"])))=>i.map(i=>d[i]);import{a as b}from"./shared.js";import"./side-effect.js";const p=()=>import(`./Page.js`),__vite__mapDeps([0,1]);const note="mentioned.js";"#, - ), - ("assets/vendor.js", "export const vendor = 1;"), - ("assets/shared.js", r#"export*from'../lib/deep.js';"#), - ("lib/deep.js", r#"const q=import("https://cdn.example/x.js");"#), - ("assets/side-effect.js", ""), - ("assets/Page.js", ""), - ("assets/preloaded.js", ""), - ("assets/mentioned.js", ""), - ("assets/stale.js", r#"import"./index.js";"#), - ] - .into_iter() - .map(|(path, text)| (path.to_owned(), text.to_owned())) - .collect(); - let reachable = reachable_modules(&modules); - let expected = [ - "assets/Page.js", - "assets/index.js", - "assets/preloaded.js", - "assets/shared.js", - "assets/side-effect.js", - "assets/vendor.js", - "index.html", - "lib/deep.js", - ]; - assert_eq!( - reachable.iter().map(String::as_str).collect::>(), - expected - ); - assert_eq!(resolve_reference("assets/a.js", "../../x.js"), None); - } -} diff --git a/build_support/native_ui.rs b/build_support/native_ui.rs new file mode 100644 index 000000000..fd692e623 --- /dev/null +++ b/build_support/native_ui.rs @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +//! Native UI build contract. Kept dependency-free for focused build validation. +use std::{fs, io, path::Path}; + +pub fn no_ui(value: Option<&str>, debug: bool, has_assets: bool) -> io::Result { + match value { + None | Some("0") => Ok(false), + Some("1") if !debug => Err(io::Error::other( + "P_BUILD_NO_UI=1 is only supported for debug backend development; release builds require NEXT_ASSETS_PATH", + )), + Some("1") if has_assets => Err(io::Error::other( + "P_BUILD_NO_UI=1 conflicts with NEXT_ASSETS_PATH; unset one", + )), + Some("1") => Ok(true), + Some(_) => Err(io::Error::other("P_BUILD_NO_UI must be 0 or 1")), + } +} + +pub fn validate(path: &Path) -> io::Result<()> { + let index = fs::read_to_string(path.join("index.html")).map_err(|err| { + io::Error::new(err.kind(), format!( + "NEXT_ASSETS_PATH {} has no readable index.html; build frontend/ with `npm run build`: {err}", path.display(), + )) + })?; + if !index.contains(r#"; rebuild frontend/ with `npm run build`", + )); + } + if !path.join("assets").is_dir() + || !fs::read_dir(path.join("assets"))?.any(|entry| { + entry.is_ok_and(|entry| entry.path().extension().is_some_and(|ext| ext == "js")) + }) + { + return Err(io::Error::other( + "NEXT_ASSETS_PATH has no compiled JavaScript in assets/; rebuild frontend/ with `npm run build`", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_ui_is_explicit_debug_only_and_unambiguous() { + assert!(!no_ui(None, true, false).unwrap()); + assert!(!no_ui(Some("0"), false, true).unwrap()); + assert!(no_ui(Some("1"), true, false).unwrap()); + assert!(no_ui(Some("1"), false, false).is_err()); + assert!(no_ui(Some("1"), true, true).is_err()); + assert!(no_ui(Some("true"), true, false).is_err()); + } + + #[test] + fn native_assets_must_be_a_complete_build() { + let root = std::env::temp_dir().join(format!("parseable-native-ui-{}", std::process::id())); + fs::create_dir_all(&root).unwrap(); + assert!(validate(&root).is_err()); + fs::write(root.join("index.html"), "").unwrap(); + assert!(validate(&root).is_err()); + fs::write(root.join("index.html"), r#""#).unwrap(); + assert!(validate(&root).is_err()); + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("assets/index-abc123.js"), "export {};").unwrap(); + validate(&root).unwrap(); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/docs/community-promql-ui.md b/docs/community-promql-ui.md index 4c1a2b47e..3851e2095 100644 --- a/docs/community-promql-ui.md +++ b/docs/community-promql-ui.md @@ -1,151 +1,9 @@ -# Community PromQL in Prism +# Community PromQL UI -The community server advertises `capabilities.promql`, `capabilities.promqlDashboard`, -`capabilities.promqlMetadata`, and `capabilities.promqlAlerts` in `/api/v1/about`. Prism uses these independent -capabilities for metric exploration, dashboard tile creation/editing/querying, and -PromQL dashboard variables. The server remains licensed as `OSS`; Enterprise AI, -summarization, and unrelated features keep their existing checks. +Community PromQL is exposed by the native frontend in `frontend/`. The old +compiled Prism overlay and archive download have been removed. Follow the +[native UI build and compatibility notes](native-ui.md) to build from source. +The [PromQL API guide](promql.md) describes the supported language and limits. -Prism v3.2.5 (the `assets-url` pinned in `Cargo.toml`) is supplied as compiled assets. The public -[parseablehq/console](https://github.com/parseablehq/console) source is an older UI -from May 2025 and does not contain the current PromQL dashboard implementation. -The supplied distribution has no source maps. This change therefore uses a small, -reproducible compatibility overlay over the supplied UI. Its readable capability -selector is `scripts/community-promql-capabilities.js`; the exact edits and input -SHA256 hashes are recorded in `scripts/community-ui-overlay.json`. - -Seven modules are changed: the dashboard editor and variable form, the -dashboard tile renderer/actions, metrics chart/explorer capability checks, -the shared alert evaluation form, the create/edit alert query guards, and the alert detail view. -All original Enterprise selectors remain unchanged. An explicit boolean capability -overrides the previous feature check; servers without capability metadata retain -their prior Enterprise behavior. PromQL alert actions use their independent `promqlAlerts` capability. -The v3 overlay adds `promqlConfig.holdDuration` (for example `5m`, default `0s`) -to the native create/edit form alongside its dataset, expression, threshold, -evaluation interval, and target controls. PromQL rules may omit targets for state -tracking only; the form explains that notifications require a target. A separate instant preview displays -series labels, numeric values, and threshold results without sending notifications; -empty vectors show No Data and failed or non-vector queries show an error. -The alert detail view displays persisted evaluation health, errors, and per-series -pending/firing/resolved state from `promqlRuntime`. Failed notification deliveries -show their target, last error, attempt count, and whether retries are pending or exhausted. - -Dashboard Create Alert resolves the current dataset and query variables into -explicit selections before opening the native form. The saved rule contains -those concrete values. Multiple queries, multiple datasets, or unresolved -variables keep the action disabled with an actionable explanation. - -Editor previews resolve dataset and query variables using the same interpolation -helpers as saved tiles. The resolved dataset and expression are part of the query -cache key; the original variable templates remain in the editor and saved tile. -An unresolved dataset variable produces an actionable message, and preview API -or network failures appear as errors instead of an empty preview. - -The existing UI calls `/prometheus/api/v1/query`, `query_range`, `labels`, and -`label/{name}/values` using normal session authentication and a `stream` parameter. -The server validates that parameter using the same dataset authorization as the -`X-P-Stream` header. Backend errors for unsupported PromQL operations remain visible -in the existing query UI; this overlay does not extend the evaluator's subset. - -Normal Cargo builds apply the overlay automatically through `build.rs` and -`build_support/community_ui.rs`, then embed the prepared assets in the binary. -This build path needs no Node installation. The standard, debug, and Kafka Docker -builds include the manifest, capability helper, and Rust build support before -compilation. An explicit `LOCAL_ASSETS_PATH` embeds the supplied directory directly; -to use community capabilities with that override, supply an already-prepared UI. - -To prepare an independent assets directory manually: - -```sh -node scripts/prepare-community-ui.mjs /path/to/stock/dist /path/to/new/dist -node --test scripts/test-community-ui.mjs -``` - -The preparation tool verifies the original bytes and exact transformation counts -before creating the output. A different Prism release or altered stock module -fails closed and requires review of a new manifest. - -Hashes and counts alone cannot detect a stale manifest: the upstream `build.zip` -also contains hashed chunks from earlier Prism builds (the v3.2.5 archive still -ships every v3.2.4 chunk), so patches to an old chunk verify but never run. -Both the Rust build and the Node tool therefore also check that: - -- the manifest `version` names the pinned release (`prism-v3.2.5-...` for - `.../v3.2.5/build.zip`); -- every patched file is reachable from `index.html` in the stock UI, following - `'); - const mapping = await bustJavaScriptCache(directory, '0123456789ab'); - const module = await import(pathToFileURL(join(directory, 'assets', mapping.get('index-main.js'))).href); - assert.equal(module.value, 42); - assert.equal((await module.load()).value, 42); - assert.deepEqual(module.deps, ['assets/gates-community-0123456789ab.js']); - // Each asset ships once, under its renamed graph only. - assert.deepEqual((await readdir(join(directory, 'assets'))).sort(), ['gates-community-0123456789ab.js', 'index-main-community-0123456789ab.js']); - const html = await readFile(join(directory, 'index.html'), 'utf8'); - assert.match(html, /index-main-community-0123456789ab\.js/); - assert.match(html, /\/assets\/style\.css/); - assert.equal(rewriteJavaScriptReferences('prefix-gates.js gates.js unrelated.js', mapping), 'prefix-gates.js gates-community-0123456789ab.js unrelated.js'); - } finally { - await rm(directory, { recursive: true, force: true }); - } -}); - -test('alert capability is independent and explicit false wins over Enterprise', () => { - assert.equal(capabilities.promqlAlerts(state('OSS', {promqlAlerts:true, promqlDashboard:false})), true); - assert.equal(capabilities.promqlAlerts(state('Enterprise', {promqlAlerts:false})), false); - assert.equal(capabilities.promqlAlerts(state('OSS', {promqlDashboard:true})), false); -}); - -test('dashboard alerts require one concrete dataset and expression', () => { - assert.equal(capabilities.concretePromqlAlert({dbName:['metrics'],chartQuery:['load{host="mac"}']}), true); - for (const tile of [ - {dbName:['$metrics_dataset'],chartQuery:['load']}, - {dbName:['metrics'],chartQuery:['load{host="$host"}']}, - {dbName:['metrics'],chartQuery:['load{host="${host}"}']}, - {dbName:['metrics'],chartQuery:['load','cpu']}, - {dbName:[],chartQuery:['load']}, - {dbName:['metrics'],chartQuery:['']}, - ]) assert.equal(capabilities.concretePromqlAlert(tile), false); -}); - -test('numeric preview uses the selected threshold and rejects invalid samples', () => { - assert.equal(capabilities.promqlCondition('8.5', '>', 8), true); - assert.equal(capabilities.promqlCondition('8', '>', 8), false); - assert.equal(capabilities.promqlCondition('8', '>=', 8), true); - assert.equal(capabilities.promqlCondition('-2', '<', 0), true); - assert.equal(capabilities.promqlCondition('8', '=', 8), true); - assert.equal(capabilities.promqlCondition('8', '!=', 8), false); - assert.throws(()=>capabilities.promqlCondition('NaN', '>', 8), /finite/); - assert.throws(()=>capabilities.promqlCondition('Infinity', '>', 8), /finite/); - assert.throws(()=>capabilities.promqlCondition(8, 'unknown', 8), /operator/); -}); - -test('hold editor preserves native alert settings and preview sends only an instant query', async () => { - const updates = [], hookValues = [], requests = []; - let hook = 0; - const react = {createElement:(type, props, ...children)=>({type,props:props || {},children}),useState:initial=>{const index=hook++;return [initial,value=>{hookValues[index]=value}];},useEffect:()=>{}}; - const alert = {query:'load{host="mac"}',datasets:['metrics'],queryType:'promql',targets:['webhook'],thresholdConfig:{operator:'>',value:8},promqlConfig:{holdDuration:'5m'}}; - const tree=capabilities.CommunityPromqlAlertDetails({react,alert,onChange:value=>updates.push(value),queryInstant:async params=>{requests.push(params);return {data:{status:'success',data:{resultType:'vector',result:[{metric:{host:'mac'},value:[1,'9']}]}}};}}); - const input=tree.children[0].children[1]; - input.props.onChange({target:{value:'10m'}}); - assert.equal(updates[0].promqlConfig.holdDuration,'10m'); - assert.deepEqual(updates[0].targets,alert.targets); - await tree.children[2].props.onClick(); - assert.deepEqual(requests,[{query:alert.query,stream:'metrics'}]); - assert.deepEqual(hookValues[0],[{labels:{host:'mac'},value:'9',breached:true}]); - assert.equal(hookValues[2],false); -}); - -test('instant preview distinguishes no data, unsupported result types and HTTP errors', async () => { - for (const [response,expectedError,expectedPreview] of [ - [{data:{status:'success',data:{resultType:'vector',result:[]}}},null,[]], - [{data:{status:'success',data:{resultType:'scalar',result:[1,'9']}}},'Alerts require an instant vector of numeric samples.',null], - [{data:{status:'error',error:'unsupported function'}},'unsupported function',null], - ]) { - const values=[];let hook=0; - const react={createElement:(type,props,...children)=>({type,props,children}),useState:initial=>{const index=hook++;return [initial,value=>values[index]=value]},useEffect:()=>{}}; - const tree=capabilities.CommunityPromqlAlertDetails({react,alert:{query:'load',datasets:['metrics'],thresholdConfig:{operator:'>',value:8}},onChange:()=>{},queryInstant:async()=>response}); - await tree.children[2].props.onClick(); - assert.equal(values[1],expectedError); - assert.deepEqual(values[0],expectedPreview); - } -}); - -test('persisted delivery failures expose target, attempts and exhausted retries', () => { - const react={createElement:(type,props,...children)=>({type,props,children}),useState:initial=>[initial,()=>{}],useEffect:()=>{}}; - const tree=capabilities.CommunityPromqlAlertDetails({react,readOnly:true,alert:{promqlRuntime:{health:'ok',instances:{},deliveries:[{labels:{host:'mac'},firing:true,target:'target-id',attempts:3,error:'Connection refused'}]}}}); - const delivery=tree.children.at(-1); - assert.equal(delivery.props.role,'alert'); - const text=delivery.children[1].children[0]; - assert.match(text,/target target-id/); - assert.match(text,/3\/3 attempts \(retries exhausted\)/); - assert.match(text,/Connection refused/); -}); - -test('OIDC mapping requires active configured OSS capability and preserves license', () => { - const configured = state('OSS', {oidcRoleMapping: true, oidcRoleSync: true}); - configured.app.instanceConfig.oidcActive = true; - assert.equal(capabilities.oidcRoleMapping(configured), true); - assert.equal(configured.app.instanceConfig.license.plan, 'OSS'); - for (const candidate of [{}, state('OSS', {oidcRoleMapping: true}), state('OSS', {})]) { - assert.equal(capabilities.oidcRoleMapping(candidate), false); - } - configured.app.instanceConfig.capabilities.oidcRoleMapping = false; - assert.equal(capabilities.oidcRoleMapping(configured), false); - configured.app.instanceConfig.capabilities.oidcRoleMapping = true; - configured.app.instanceConfig.license.plan = 'Enterprise'; - assert.equal(capabilities.oidcRoleMapping(configured), false); -}); - -test('mapping preserves exact provider group names and requires no native group API', () => { - assert.deepEqual(capabilities.oidcGroupMappings({'Ops.Read': [], ingestor: []}, 'OPS'), [{group: 'Ops.Read', role: 'Ops.Read'}]); - assert.deepEqual(capabilities.oidcGroupMappings(null), []); - const react = {useState: value => [value, () => {}], createElement: (type, props, ...children) => ({type, props, children})}; - const tree = capabilities.CommunityOidcGroups({react, roles: {'Ops.Read': []}, roleSync: true}); - const content = JSON.stringify(tree); - assert.match(content, /case-sensitive/); - assert.match(content, /ID token/); - assert.match(content, /five minutes/); - assert.match(content, /explicitly assigned/); - assert.doesNotMatch(content, /usergroup|Enterprise/); - assert.doesNotMatch(JSON.stringify(capabilities.CommunityOidcGroups({react, roles: {}})), /five minutes/); -}); - -function oidcDefaultHarness(api, roles, onClose = () => {}) { - const values = [], effects = []; - let hook = 0, mounted = false; - const react = { - createElement: (type, props, ...children) => ({type, props: props || {}, children}), - useState: initial => { const index = hook++; if (!(index in values)) values[index] = initial; return [values[index], value => { values[index] = value; }]; }, - useEffect: effect => { if (!mounted) effects.push(effect); }, - }; - function render() { hook = 0; const tree = capabilities.CommunityOidcDefaultRole({react, api, roles, onClose}); mounted = true; return tree; } - const first = render(); - effects.forEach(effect => effect()); - return {first, render, values}; -} - -function elements(tree, type) { - if (!tree || typeof tree !== 'object') return []; - return [...(tree.type === type ? [tree] : []), ...(tree.children || []).flatMap(child => elements(child, type))]; -} - -test('default editor loads null, saves an existing role as JSON, and clears with DELETE', async () => { - const requests = []; let closes = 0; - const editor = oidcDefaultHarness({ - get: async path => { requests.push(['GET', path]); return {data: null}; }, - put: async (...args) => { requests.push(['PUT', ...args]); return {data: ''}; }, - delete: async path => { requests.push(['DELETE', path]); return {data: ''}; }, - }, {reader: []}, () => closes++); - assert.equal(elements(editor.first, 'button')[0].props.disabled, true); - await new Promise(resolve => setImmediate(resolve)); - let tree = editor.render(); - assert.match(JSON.stringify(tree), /Current default: None/); - assert.equal(elements(tree, 'button')[1].props.disabled, true); - elements(tree, 'select')[0].props.onChange({target: {value: 'reader'}}); - tree = editor.render(); - assert.equal(elements(tree, 'button')[0].props.disabled, false); - await elements(tree, 'button')[0].props.onClick(); - tree = editor.render(); - assert.match(JSON.stringify(tree), /Current default: reader/); - assert.equal(elements(tree, 'button')[1].props.disabled, false); - await elements(tree, 'button')[1].props.onClick(); - assert.match(JSON.stringify(editor.render()), /Current default: None/); - assert.equal(closes, 2); - assert.deepEqual(requests, [ - ['GET', '/api/v1/role/default'], - ['PUT', '/api/v1/role/default', 'reader', {headers: {'Content-Type': 'application/json'}}], - ['DELETE', '/api/v1/role/default'], - ]); -}); - -test('default editor displays server failure and keeps current role and dialog', async () => { - let closes = 0; - const editor = oidcDefaultHarness({ - get: async () => ({data: 'reader'}), - delete: async () => { throw {response: {data: 'Default role update failed'}, message: 'HTTP 500'}; }, - }, {reader: []}, () => closes++); - await new Promise(resolve => setImmediate(resolve)); - await elements(editor.render(), 'button')[1].props.onClick(); - const tree = editor.render(); - assert.equal(closes, 0); - assert.match(JSON.stringify(tree), /Current default: reader/); - assert.equal(elements(tree, 'p').find(node => node.props.role === 'alert').children[0], 'Default role update failed'); - assert.equal(elements(tree, 'button')[1].props.disabled, false); -}); - -test('failed default lookup displays Unknown and a successful explicit save establishes current role', async () => { - const editor = oidcDefaultHarness({ - get: async () => { throw {response: {data: 'Cannot load default role'}}; }, - put: async () => ({data: ''}), - }, {reader: []}); - await new Promise(resolve => setImmediate(resolve)); - let tree = editor.render(); - assert.match(JSON.stringify(tree), /Current default: Unknown/); - assert.doesNotMatch(JSON.stringify(tree), /Current default: None/); - assert.equal(elements(tree, 'p').find(node => node.props.role === 'alert').children[0], 'Cannot load default role'); - assert.equal(elements(tree, 'button')[1].props.disabled, true); - elements(tree, 'select')[0].props.onChange({target: {value: 'reader'}}); - await elements(editor.render(), 'button')[0].props.onClick(); - assert.match(JSON.stringify(editor.render()), /Current default: reader/); -}); - -test('OIDC overlay retains native groups gate and handles clearing the role marker', async () => { - const edits = (await overlayFile('Team')).replacements; - assert.ok(edits.some(edit => edit.to.includes('communityOidcEnabled?(0,$.jsx)(communityOidcGroupView') && edit.to.includes('(0,$.jsx)(yt,'))); - assert.ok(edits.some(edit => /\w+===`groups`&&!communityOidcEnabled/.test(edit.to))); - assert.ok(edits.some(edit => edit.from === 's?.data&&p(s?.data)' && edit.to === 's&&p(s.data??null)')); - assert.ok(edits.some(edit => edit.to.includes('communityNativeDefaultRole,props'))); -}); - -test('provider-only roles cannot be removed as manual grants, legacy roles always can', () => { - const summary = {oidc: {legacy: false, manualRoles: ['reader'], providerRoles: ['reader', 'writer']}}; - assert.equal(capabilities.oidcManualRoleRemovable(summary, 'reader'), true); - assert.equal(capabilities.oidcManualRoleRemovable(summary, 'writer'), false); - assert.equal(capabilities.oidcManualRoleRemovable({oidc: {legacy: true, manualRoles: null}}, 'reader'), true); - assert.equal(capabilities.oidcManualRoleRemovable({}, 'reader'), false); -}); - -test('role inspector fetches safe provenance with encoded user ID and explains overlapping grants', async () => { - const values = [], inspections = [], requests = [], effects = []; - let hook = 0, mounted = false; - const react = { - createElement: (type, props, ...children) => ({type, props: props || {}, children}), - useState: initial => { const index = hook++; if (!(index in values)) values[index] = initial; return [values[index], value => values[index] = value]; }, - useEffect: effect => { if (!mounted) effects.push(effect); }, - }; - const props = {react, userId: 'issuer/sub', role: 'reader', onInspection: value => inspections.push(value), - api: {get: async path => {requests.push(path); return {data: {roles: {reader: []}, oidc: {legacy: false, groups: ['reader'], providerRoles: ['reader'], manualRoles: ['reader'], defaultRole: 'fallback'}}}; }}, - }; - capabilities.CommunityOidcRoleInspector(props); mounted = true; effects.forEach(effect => effect()); - await new Promise(resolve => setImmediate(resolve)); - hook = 0; - const tree = capabilities.CommunityOidcRoleInspector(props); - assert.deepEqual(requests, ['/api/v1/user/issuer%2Fsub/role']); - assert.deepEqual(inspections, [false, true]); - assert.match(JSON.stringify(tree), /Effective direct roles: reader/); - assert.match(JSON.stringify(tree), /Removing the manual grant leaves this role assigned/); - assert.match(JSON.stringify(tree), /Fallback role \(when no manual or provider role\): fallback/); -}); - -test('error account recovery works without application providers and navigates to server logout', async () => { - const React = {useState: value => [value, () => {}], createElement: (type, props, ...children) => ({type, props, children})}; - const menu = capabilities.CommunityErrorAccountMenu({react: React}); - assert.equal(menu.type, 'details'); - assert.equal(menu.children[0].props['aria-label'], 'Open user menu'); - assert.equal(menu.children[1].children[0].children[0], 'Sign out'); - assert.equal(typeof menu.children[1].children[0].props.onClick, 'function'); - const removed = []; - let target; - const browser = {localStorage: {removeItem: key => removed.push(key)}, location: {origin: 'https://parseable.example', assign: url => {target = url;}}}; - capabilities.errorAccountLogout(browser); - assert.deepEqual(removed, ['authToken']); - // A top-level navigation lets the browser follow the redirect to the IdP end-session endpoint. - const logout = new URL(target, browser.location.origin); - assert.equal(logout.pathname, '/api/v1/o/logout'); - assert.equal(logout.searchParams.get('redirect'), 'https://parseable.example/login'); - browser.localStorage.removeItem = () => {throw Error('Storage blocked');}; - target = null; - capabilities.errorAccountLogout(browser); - assert.ok(target, 'storage failure must not prevent server session logout'); -}); - -test('error overlay covers root, route, and application initialization failures', async () => { - const root = await overlayFile('index'); - assert.ok(root.replacements.some(item => item.from.includes('error-boundary') && item.to.includes('CommunityErrorAccountMenu'))); - assert.ok(root.replacements.some(item => item.from.includes('Parseable Error Icon') && item.to.includes('CommunityErrorAccountMenu'))); - // The native navigation user menu (NavUser) supplies account actions in the application shell. - const app = await overlayFile('App'); - assert.ok(app.replacements.some(item => item.from.includes('Parseable Error Icon') && /fixed right-4 top-4 z-50`,children:\(0,\w+\.jsx\)\(\w+,\{\}\)/.test(item.to))); -}); - -test('standalone no-access and missing-page errors retain account recovery', async () => { - for (const prefix of ['NoAccessPage', 'NotFoundPage']) { - const file = await overlayFile(prefix); - assert.ok(file.replacements.some(item => item.from.includes('Parseable Error Icon') && item.to.includes('CommunityErrorAccountMenu'))); - } -}); - -test('route recovery stays within its panel and account trigger has no list marker', async () => { - const React = {useState: value => [value, () => {}], createElement: (type, props, ...children) => ({type, props, children})}; - const fixed = capabilities.CommunityErrorAccountMenu({react: React}); - const panel = capabilities.CommunityErrorAccountMenu({react: React, position: 'absolute'}); - assert.equal(fixed.props.style.position, 'fixed'); - assert.equal(panel.props.style.position, 'absolute'); - assert.equal(panel.children[0].props.style.display, 'flex'); - assert.equal(panel.children[0].props.style.listStyle, 'none'); - assert.equal(panel.children[0].props.style.height, 40); - const root = await overlayFile('index'); - assert.ok(root.replacements.some(item => item.to.includes('relative h-screen'))); - assert.ok(root.replacements.some(item => item.to.includes('position:`absolute`'))); -}); - -test('account menu displays logout failure and restores retry control', async () => { - const updates = []; - const React = { - useState: value => [value, updated => updates.push(updated)], - createElement: (type, props, ...children) => ({type, props, children}), - }; - const originalWindow = globalThis.window; - globalThis.window = {location: {origin: 'https://parseable.example', assign: () => {throw Error('Navigation blocked');}}, localStorage: {removeItem: () => {}}}; - try { - const menu = capabilities.CommunityErrorAccountMenu({react: React}); - await menu.children[1].children[0].props.onClick(); - assert.deepEqual(updates, [true, '', 'Navigation blocked', false]); - const failureReact = {...React, useState: value => [typeof value === 'string' ? 'Sign out failed' : value, () => {}]}; - const failedMenu = capabilities.CommunityErrorAccountMenu({react: failureReact}); - assert.equal(failedMenu.children[1].children[1].props.role, 'alert'); - assert.equal(failedMenu.children[1].children[1].children[0], 'Sign out failed'); - } finally { - if (originalWindow === undefined) delete globalThis.window; - else globalThis.window = originalWindow; - } -}); - -const graph = entries => new Map(Object.entries(entries)); -const stockGraph = () => graph({ - 'index.html': '', - 'assets/index.js': 'const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Page.js","assets/preloaded.js"])))=>i.map(i=>d[i]);import{a as b}from"./shared.js";import"./side-effect.js";const p=()=>import(`./Page.js`),__vite__mapDeps([0,1]);const note="mentioned.js";', - 'assets/vendor.js': 'export const vendor = 1;', - 'assets/shared.js': "export*from'../lib/deep.js';", - 'lib/deep.js': 'const q=import("https://cdn.example/x.js");', - 'assets/side-effect.js': '', - 'assets/Page.js': '', - 'assets/preloaded.js': '', - 'assets/mentioned.js': '', - 'assets/stale.js': 'import"./index.js";', -}); - -test('module graph follows HTML, static, dynamic and Vite preload references only', () => { - assert.deepEqual([...reachableModules(stockGraph())].sort(), [ - 'assets/Page.js', 'assets/index.js', 'assets/preloaded.js', 'assets/shared.js', - 'assets/side-effect.js', 'assets/vendor.js', 'index.html', 'lib/deep.js', - ]); -}); - -test('overlay refuses patches to chunks the UI never loads', () => { - // Stale chunks from older Prism builds ship in the same ZIP; their hashes and - // replacement counts still verify, so reachability is the only signal. - const stock = stockGraph(); - const patched = new Map(stock); - patched.set('assets/stale.js', 'import"./community.js";' + stock.get('assets/stale.js')); - patched.set('assets/community.js', ''); - assert.throws(() => verifyReachability(stock, patched, ['assets/stale.js'], ['assets/community.js'], 'test'), - /test: overlay files not reachable from index\.html: assets\/stale\.js, assets\/community\.js/); - patched.delete('assets/stale.js'); - patched.set('assets/Page.js', 'import"./community.js";'); - verifyReachability(stock, patched, ['assets/Page.js'], ['assets/community.js'], 'test'); -}); - -test('overlay refuses patches that import modules outside the stock graph', () => { - const stock = stockGraph(); - const patched = new Map(stock); - patched.set('assets/Page.js', 'import"./community.js";import{x}from"./stale.js";'); - patched.set('assets/community.js', ''); - assert.throws(() => verifyReachability(stock, patched, ['assets/Page.js'], ['assets/community.js'], 'test'), - /outside the Prism UI graph: assets\/stale\.js/); -}); - -test('overlay manifest matches the Prism release pinned in Cargo.toml', async () => { - assert.throws(() => verifyPrismVersion('prism-v3.2.4-community', 'https://example/build/v3.2.5/build.zip'), /does not match the pinned Prism UI v3\.2\.5/); - verifyPrismVersion('prism-v3.2.5-community', 'https://example/build/v3.2.5/build.zip'); - const manifest = JSON.parse(await readFile(new URL('./community-ui-overlay.json', import.meta.url), 'utf8')); - const cargo = await readFile(new URL('../Cargo.toml', import.meta.url), 'utf8'); - verifyPrismVersion(manifest.version, cargo.match(/^assets-url\s*=\s*"([^"]+)"/m)[1]); -}); diff --git a/src/cli.rs b/src/cli.rs index bbe0923d0..00c41f627 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -162,13 +162,12 @@ pub struct Options { )] pub allow_origins: Vec, - // Unset means native. Kept apart so a build without the native frontend only - // warns when the operator asked for it. + // Retained to reject an obsolete classic override with an actionable error. #[arg( long, env = "P_UI_ROOT", value_parser = validation::ui_root, - help = "UI served at /: native (default), or classic to restore the Prism UI with the native frontend under /next" + help = "UI served at /: native (default). The classic UI has been removed" )] pub ui_root: Option, diff --git a/src/handlers/http/modal/mod.rs b/src/handlers/http/modal/mod.rs index 492fe99ea..2cc1ef70e 100644 --- a/src/handlers/http/modal/mod.rs +++ b/src/handlers/http/modal/mod.rs @@ -63,7 +63,6 @@ pub static OIDC_CLIENT: OnceCell>>> = OnceCell pub const DEFAULT_VERSION: &str = "v4"; include!(concat!(env!("OUT_DIR"), "/generated.rs")); -include!(concat!(env!("OUT_DIR"), "/next_generated.rs")); #[async_trait] pub trait ParseableServer { diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index c40be0234..ec047d282 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -16,8 +16,7 @@ * */ -use std::collections::HashMap; -use std::sync::{LazyLock, Mutex, Once}; +use std::sync::Once; use std::thread; use crate::analytics; @@ -46,7 +45,6 @@ use crate::hottier::HotTierMessage; use crate::hottier::hottier_runtime; use crate::metrics; use crate::migration; -use crate::option::UiRoot; use crate::storage; use crate::storage::field_stats::get_dataset_stats; use crate::sync; @@ -81,10 +79,9 @@ use crate::{ rbac::role::Action, }; -// use super::generate; use super::ParseableServer; +use super::generate; use super::load_on_init; -use super::{generate, generate_next}; pub struct Server; @@ -855,43 +852,25 @@ impl Server { .route(web::post().to(get_dataset_stats).authorize(Action::Query)) } - // GET "/" ==> Serve the UI chosen by P_UI_ROOT - // GET "/next" ==> Serve the native frontend beside the classic UI, or redirect to "/" + // Native UI at `/`; preserve old `/next` bookmarks and OIDC return URLs. pub fn configure_ui(config: &mut web::ServiceConfig) { - Self::mount_ui( - config, - generate(), - generate_next(), - PARSEABLE.options.ui_root, - ); + Self::mount_ui(config, generate()); } - /// The native frontend serves `/` unless `root` is classic or no native build - /// was embedded; the classic UI then serves `/` and the native one, if any, - /// `/next`. `/next` is registered ahead of the catch-all `/` either way. - /// `root` is `None` when `P_UI_ROOT` is unset, which means native. - fn mount_ui( - config: &mut web::ServiceConfig, - classic: UiFiles, - native: UiFiles, - root: Option, - ) { + fn mount_ui(config: &mut web::ServiceConfig, native: UiFiles) { + config.service(web::scope("/next").default_service(web::to(redirect_from_next))); if native.is_empty() { static LOGGED: Once = Once::new(); - LOGGED.call_once(|| match root { - Some(UiRoot::Native) => tracing::warn!( - "P_UI_ROOT=native, but no native frontend was embedded; serving the classic UI" - ), - Some(UiRoot::Classic) => {} - None => tracing::info!("No native frontend was embedded; serving the classic UI"), - }); - config.service(Self::ui_scope("", classic)); - } else if root.unwrap_or_default() == UiRoot::Native { - config.service(web::scope("/next").default_service(web::to(redirect_from_next))); - config.service(Self::ui_scope("", native)); + LOGGED.call_once(|| tracing::warn!( + "UI disabled by P_BUILD_NO_UI=1 at build time; rebuild with NEXT_ASSETS_PATH to enable it" + )); + config.service(web::scope("").default_service(web::to(|| async { + HttpResponse::ServiceUnavailable() + .insert_header((CACHE_CONTROL, "no-store")) + .body("UI disabled in this backend development build. Rebuild with NEXT_ASSETS_PATH pointing to frontend/dist.") + }))); } else { - config.service(Self::ui_scope("/next", native)); - config.service(Self::ui_scope("", classic)); + config.service(Self::ui_scope("", native)); } } @@ -909,7 +888,7 @@ impl Server { > { // A single-page app: any path that is not a file is a client-side route // and gets `index.html`, except a missing hashed asset (see `serve_ui`). - let files = Rc::new(at_mount(files, path)); + let files = Rc::new(files); let lookup = Rc::clone(&files); web::scope(path) .wrap(from_fn(move |req: ServiceRequest, next: Next<_>| { @@ -921,40 +900,8 @@ impl Server { type UiFiles = std::collections::HashMap<&'static str, static_files::Resource>; -/// The native build loads its assets relative to ``. Pointing -/// that at the mount lets one build serve `/` and `/next`. The rewritten -/// `index.html` is a different length, so its ETag differs as well. -fn at_mount(mut files: UiFiles, mount: &str) -> UiFiles { - if !mount.is_empty() - && let Some(index) = files.get_mut("index.html") - { - index.data = mounted_index(index.data, mount); - } - files -} - -/// `index` with its base at `mount`, built once per build and mount since every -/// worker mounts the UI and the served bytes must be `'static`. -fn mounted_index(index: &'static [u8], mount: &str) -> &'static [u8] { - type Key = (usize, String); - static MOUNTED: LazyLock>> = LazyLock::new(Default::default); - MOUNTED - .lock() - .unwrap() - .entry((index.as_ptr() as usize, mount.to_owned())) - .or_insert_with(|| { - let html = String::from_utf8_lossy(index).replacen( - r#" HttpResponse { } /// Vite names every file it emits under `assets/` after a hash of its content, -/// in both UIs. Only the prefix is checked: client routes such as Prism's +/// in the native UI. Only the prefix is checked: legacy client routes such as /// `/apm/` may end in a segment containing a dot. `path` must be /// normalized, so that `assets/` with nothing after it is not a file. fn is_hashed_asset(path: &str) -> bool { @@ -1105,50 +1052,22 @@ mod tests { ]) } - async fn call_with( - request: test::TestRequest, - path: &str, - native: UiFiles, - root: Option, - ) -> ServiceResponse { - let app = test::init_service( - App::new().configure(|config| Server::mount_ui(config, ui(b"classic"), native, root)), - ) - .await; + async fn call(request: test::TestRequest, path: &str, native: UiFiles) -> ServiceResponse { + let app = + test::init_service(App::new().configure(|config| Server::mount_ui(config, native))) + .await; test::call_service(&app, request.uri(path).to_request()).await } - async fn serve(path: &str, native: UiFiles, root: UiRoot) -> ServiceResponse { - serve_with(path, native, Some(root)).await - } - - async fn serve_with(path: &str, native: UiFiles, root: Option) -> ServiceResponse { - call_with(test::TestRequest::get(), path, native, root).await + async fn get(path: &str, native: UiFiles) -> ServiceResponse { + call(test::TestRequest::get(), path, native).await } - /// With the classic UI at `/` and the native one beside it under `/next`. - async fn call(request: test::TestRequest, path: &str, next: UiFiles) -> ServiceResponse { - call_with(request, path, next, Some(UiRoot::Classic)).await - } - - /// With the classic UI at `/` and the native one beside it under `/next`. - async fn get(path: &str, next: UiFiles) -> ServiceResponse { - serve(path, next, UiRoot::Classic).await - } - - /// With the classic UI at `/` and the native one beside it under `/next`. - async fn get_and_revalidate(path: &str, next: UiFiles) -> [ServiceResponse; 2] { - revalidate(path, next, UiRoot::Classic).await - } - - /// GETs `path`, then again with the first response's ETag in `If-None-Match`, - /// as a browser revalidating its copy does. Returns both responses. - async fn revalidate(path: &str, native: UiFiles, root: UiRoot) -> [ServiceResponse; 2] { - let app = test::init_service( - App::new() - .configure(|config| Server::mount_ui(config, ui(b"classic"), native, Some(root))), - ) - .await; + /// Revalidate an actual response using its ETag, as browsers do. + async fn get_and_revalidate(path: &str, native: UiFiles) -> [ServiceResponse; 2] { + let app = + test::init_service(App::new().configure(|config| Server::mount_ui(config, native))) + .await; let first = test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await; let etag = first.headers().get(ETAG).expect("an ETag").clone(); let request = test::TestRequest::get() @@ -1164,16 +1083,12 @@ mod tests { [first, second] } - async fn body_from(path: &str, native: UiFiles, root: UiRoot) -> Bytes { - let response = serve(path, native, root).await; + async fn body_at(path: &str, native: UiFiles) -> Bytes { + let response = get(path, native).await; assert!(response.status().is_success(), "GET {path} failed"); to_bytes(response.into_body()).await.unwrap() } - async fn body_at(path: &str, next: UiFiles) -> Bytes { - body_from(path, next, UiRoot::Classic).await - } - fn location(response: &ServiceResponse) -> &str { response.headers().get(LOCATION).unwrap().to_str().unwrap() } @@ -1187,26 +1102,6 @@ mod tests { .unwrap() } - #[actix_web::test] - async fn next_routes_take_precedence_over_the_root_fallback() { - for path in [ - "/next", - "/next/", - "/next/logs?x=1", - "/next/oidc-not-configured", - ] { - assert_eq!(body_at(path, ui(b"next")).await, "next", "GET {path}"); - } - for path in ["/", "/logs", "/nextfoo", "/oidc-not-configured"] { - assert_eq!(body_at(path, ui(b"next")).await, "classic", "GET {path}"); - } - } - - #[actix_web::test] - async fn without_a_next_build_the_classic_ui_serves_next_paths() { - assert_eq!(body_at("/next/logs", UiFiles::new()).await, "classic"); - } - #[actix_web::test] async fn client_routes_get_index_html_even_with_a_dot_in_them() { for path in [ @@ -1214,9 +1109,9 @@ mod tests { "/errors/TypeError.x", "/assetsfoo/a.js", ] { - assert_eq!(body_at(path, UiFiles::new()).await, "classic", "GET {path}"); + assert_eq!(body_at(path, ui(b"native")).await, "native", "GET {path}"); } - assert_eq!(body_at("/next/apm/a.b", ui(b"next")).await, "next"); + assert_eq!(body_at("/apm/a.b", ui(b"native")).await, "native"); } #[actix_web::test] @@ -1235,14 +1130,14 @@ mod tests { let response = call( test::TestRequest::post(), "/assets/index-gone.js", - UiFiles::new(), + ui(b"native"), ) .await; assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); let response = call( test::TestRequest::default().method(Method::HEAD), "/assets/index-gone.js", - UiFiles::new(), + ui(b"native"), ) .await; assert_eq!(response.status(), StatusCode::NOT_FOUND); @@ -1250,17 +1145,13 @@ mod tests { #[actix_web::test] async fn hashed_assets_are_immutable_and_everything_else_revalidates() { - for path in [ - "/assets/index-abc123.js", - "/assets//index-abc123.js", - "/next/assets/index-abc123.js", - ] { + for path in ["/assets/index-abc123.js", "/assets//index-abc123.js"] { let [response, revalidated] = get_and_revalidate(path, ui(b"next")).await; assert_eq!(response.status(), StatusCode::OK, "GET {path}"); assert_eq!(cache_control(&response), IMMUTABLE, "GET {path}"); assert_eq!(cache_control(&revalidated), IMMUTABLE, "revalidate {path}"); } - for path in ["/", "/logs", "/favicon.ico", "/next", "/next/team"] { + for path in ["/", "/logs", "/favicon.ico"] { let [response, revalidated] = get_and_revalidate(path, ui(b"next")).await; assert_eq!(response.status(), StatusCode::OK, "GET {path}"); assert_eq!(cache_control(&response), "no-cache", "GET {path}"); @@ -1283,10 +1174,7 @@ mod tests { for path in [ "/assets/", "/assets//", - "/next/assets/", - "/next/assets//", "/assets/%2e%2e/index.html", - "/next/assets/%2E%2E/index.html", "/assets/x/%2e%2e/%2e%2e/logs", ] { let [response, revalidated] = get_and_revalidate(path, ui(b"next")).await; @@ -1308,8 +1196,7 @@ mod tests { async fn html_is_never_immutable() { let mut next = ui(b"next"); next.insert("assets/page-abc123.html", file(b"page", "text/html")); - let [response, revalidated] = - get_and_revalidate("/next/assets/page-abc123.html", next).await; + let [response, revalidated] = get_and_revalidate("/assets/page-abc123.html", next).await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(cache_control(&response), "no-cache"); assert_eq!(cache_control(&revalidated), "no-cache"); @@ -1318,26 +1205,20 @@ mod tests { #[actix_web::test] async fn existing_files_are_served_as_themselves() { assert_eq!( - body_at("/assets/index-abc123.js", UiFiles::new()).await, + body_at("/assets/index-abc123.js", ui(b"native")).await, "chunk" ); - assert_eq!(body_at("/favicon.ico", UiFiles::new()).await, "icon"); - assert_eq!(body_at("/next/favicon.ico", ui(b"next")).await, "icon"); + assert_eq!(body_at("/favicon.ico", ui(b"native")).await, "icon"); } #[actix_web::test] async fn the_native_frontend_serves_the_root_by_default() { - assert_eq!(UiRoot::default(), UiRoot::Native); for path in ["/", "/logs/explore/app", "/nextfoo", "/oidc-not-configured"] { - assert_eq!( - body_from(path, ui(b"native"), UiRoot::Native).await, - "native", - "GET {path}" - ); - let unset = serve_with(path, ui(b"native"), None).await; + assert_eq!(body_at(path, ui(b"native")).await, "native", "GET {path}"); + let unset = get(path, ui(b"native")).await; assert_eq!(to_bytes(unset.into_body()).await.unwrap(), "native"); } - let unset = serve_with("/next/logs", ui(b"native"), None).await; + let unset = get("/next/logs", ui(b"native")).await; assert_eq!(unset.status(), StatusCode::TEMPORARY_REDIRECT); } @@ -1357,19 +1238,19 @@ mod tests { "/next/%2e%2e/assets/index-abc123.js", "/next/assets//index-abc123.js", ] { - let response = serve(path, ui(b"native"), UiRoot::Native).await; + let response = get(path, ui(b"native")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}"); assert_eq!(cache_control(&response), "no-cache", "GET {path}"); assert!(response.headers().get(LOCATION).is_none(), "GET {path}"); } // At the root the native build's assets are served and cached as usual. - let response = serve("/assets/index-abc123.js", ui(b"native"), UiRoot::Native).await; + let response = get("/assets/index-abc123.js", ui(b"native")).await; assert_eq!(cache_control(&response), IMMUTABLE); assert_eq!(to_bytes(response.into_body()).await.unwrap(), "chunk"); - let response = serve("/assets/index-gone.js", ui(b"native"), UiRoot::Native).await; + let response = get("/assets/index-gone.js", ui(b"native")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); assert_eq!(cache_control(&response), "no-cache"); - let response = serve("/logs", ui(b"native"), UiRoot::Native).await; + let response = get("/logs", ui(b"native")).await; assert_eq!(cache_control(&response), "no-cache"); } @@ -1399,7 +1280,7 @@ mod tests { ("/%6Eext//evil.example", "/evil.example"), ("/%6Eext/\\evil.example", "/evil.example"), ] { - let response = serve(path, ui(b"native"), UiRoot::Native).await; + let response = get(path, ui(b"native")).await; assert_eq!( response.status(), StatusCode::TEMPORARY_REDIRECT, @@ -1409,78 +1290,20 @@ mod tests { } } - #[actix_web::test] - async fn without_a_native_build_the_classic_ui_serves_the_root() { - for root in [UiRoot::Native, UiRoot::Classic] { - assert_eq!(body_from("/logs", UiFiles::new(), root).await, "classic"); - assert_eq!( - body_from("/next/logs", UiFiles::new(), root).await, - "classic" - ); - } - } - - #[actix_web::test] - async fn the_native_build_loads_its_assets_from_its_mount() { - let native = || ui(br#""#); - assert_eq!( - body_from("/next/logs", native(), UiRoot::Classic).await, - r#""# - ); - assert_eq!( - body_from("/logs", native(), UiRoot::Native).await, - r#""# - ); - for path in ["/next", "/next/", "/next/index.html", "/next/a/b/c"] { - assert_eq!( - body_from(path, native(), UiRoot::Classic).await, - r#""#, - "GET {path}" - ); - } - let asset = serve("/next/assets/index-abc123.js", native(), UiRoot::Classic).await; - assert_eq!(to_bytes(asset.into_body()).await.unwrap(), "chunk"); - } - - /// A browser keeps the build's `index.html` from one mount apart from the - /// other's, and revalidates the rewritten one like any other. - #[actix_web::test] - async fn the_rewritten_index_has_its_own_etag() { - let native = || ui(br#""#); - let [at_next, revalidated] = revalidate("/next/logs", native(), UiRoot::Classic).await; - assert_eq!(cache_control(&at_next), "no-cache"); - assert_eq!(cache_control(&revalidated), "no-cache"); - let [at_root, _] = revalidate("/logs", native(), UiRoot::Native).await; - assert_ne!(at_next.headers().get(ETAG), at_root.headers().get(ETAG)); - let request = test::TestRequest::get() - .insert_header((IF_NONE_MATCH, at_root.headers().get(ETAG).unwrap().clone())); - let response = call(request, "/next/logs", native()).await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - r#""# - ); - } - /// The SPA fallback applies only once `ResourceFiles` has normalized the path /// and tried a directory index, as with the plain `ResourceFiles` before. #[actix_web::test] async fn paths_are_normalized_before_the_fallback() { for (path, body) in [ ("/foo/../assets/index-abc123.js", "chunk"), - ("/next/foo/../assets/index-abc123.js", "chunk"), ("/integrations-assets//k8s.svg", "svg"), - ("/next/integrations-assets//k8s.svg", "svg"), ("/docs/", "docs"), - ("/next/docs/", "docs"), - ("/logs/", "classic"), - ("/logs//x/..", "classic"), - ("/next/logs/", "next"), + ("/logs/", "next"), + ("/logs//x/..", "next"), ("/favicon.ico/", "icon"), - ("/next/favicon.ico/", "icon"), ("/integrations-assets/k8s.svg/", "svg"), - ("/favicon.ico/index.html", "classic"), - ("/index.html/index.html", "classic"), + ("/favicon.ico/index.html", "next"), + ("/index.html/index.html", "next"), ("/assets/index-abc123.js/", "chunk"), ] { assert_eq!(body_at(path, ui(b"next")).await, body, "GET {path}"); @@ -1501,13 +1324,7 @@ mod tests { /// `ResourceFiles` rejects these segments with a 400 rather than serve them. #[actix_web::test] async fn paths_the_crate_rejects_stay_bad_requests() { - for path in [ - "/.env", - "/next/.env", - "/logs/.git/config", - "/explore/x:", - "/*x", - ] { + for path in ["/.env", "/logs/.git/config", "/explore/x:", "/*x"] { let response = get(path, ui(b"next")).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST, "GET {path}"); assert_eq!(cache_control(&response), "no-cache", "GET {path}"); @@ -1515,7 +1332,7 @@ mod tests { // Even a file of that name in the build is not served. let mut next = ui(b"next"); next.insert(".env", file(b"secret", "text/plain")); - let response = get("/next/.env", next).await; + let response = get("/.env", next).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); } @@ -1523,11 +1340,44 @@ mod tests { async fn a_directory_index_under_assets_is_served() { let mut next = ui(b"next"); next.insert("assets/dir/index.html", file(b"dir", "text/html")); - let [response, revalidated] = get_and_revalidate("/next/assets/dir/", next).await; + let [response, revalidated] = get_and_revalidate("/assets/dir/", next).await; assert_eq!(cache_control(&response), "no-cache"); assert_eq!(to_bytes(response.into_body()).await.unwrap(), "dir"); assert_eq!(cache_control(&revalidated), "no-cache"); - let response = get("/next/assets/dir/", ui(b"next")).await; + let response = get("/assets/dir/", ui(b"next")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } + #[actix_web::test] + async fn backend_only_build_explains_missing_ui_and_preserves_api_routes() { + let app = test::init_service( + App::new() + .route( + "/api/prism/v1/probe", + web::get().to(|| async { HttpResponse::Ok().body("api") }), + ) + .configure(|config| Server::mount_ui(config, UiFiles::new())), + ) + .await; + for path in ["/", "/logs", "/oidc-not-configured"] { + let response = + test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(cache_control(&response), "no-store"); + assert!( + String::from_utf8(to_bytes(response.into_body()).await.unwrap().to_vec()) + .unwrap() + .contains("NEXT_ASSETS_PATH") + ); + } + let response = test::call_service( + &app, + test::TestRequest::get() + .uri("/api/prism/v1/probe") + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = get("/next/logs?x=1", UiFiles::new()).await; + assert_eq!(location(&response), "/logs?x=1"); + } } diff --git a/src/lib.rs b/src/lib.rs index e570de9f3..25d723809 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -134,7 +134,7 @@ pub static INTRA_CLUSTER_CLIENT: Lazy = Lazy::new(|| { .expect("Construction of client shouldn't fail") }); -// Compiled by build.rs; included here so its overlay logic is unit tested. +// Keep the native build contract covered without network or frontend tooling. #[cfg(test)] -#[path = "../build_support/community_ui.rs"] -mod community_ui_build; +#[path = "../build_support/native_ui.rs"] +mod native_ui_build; diff --git a/src/option.rs b/src/option.rs index 25b81fe5f..cdfad83db 100644 --- a/src/option.rs +++ b/src/option.rs @@ -36,8 +36,6 @@ pub enum UiRoot { /// The native frontend. `/next`, where it used to live, redirects to `/`. #[default] Native, - /// The classic Prism UI, with the native frontend beside it under `/next`. - Classic, } #[derive(Debug, thiserror::Error)] @@ -161,8 +159,8 @@ pub mod validation { pub fn ui_root(s: &str) -> Result { match s { "native" => Ok(UiRoot::Native), - "classic" => Ok(UiRoot::Classic), - _ => Err("Invalid P_UI_ROOT provided, expected native or classic".to_string()), + "classic" => Err("The classic Prism UI has been removed. Unset P_UI_ROOT (or use native) and rebuild with NEXT_ASSETS_PATH pointing to frontend/dist".to_string()), + _ => Err("Invalid P_UI_ROOT provided, expected native".to_string()), } } @@ -297,3 +295,17 @@ pub mod validation { } } } + +#[cfg(test)] +mod ui_tests { + use super::{UiRoot, validation}; + + #[test] + fn only_native_ui_is_supported() { + assert_eq!(validation::ui_root("native").unwrap(), UiRoot::Native); + let error = validation::ui_root("classic").unwrap_err(); + assert!(error.contains("removed")); + assert!(error.contains("Unset P_UI_ROOT")); + assert!(validation::ui_root("other").is_err()); + } +}