diff --git a/.dockerignore b/.dockerignore index 2a63a89fd..68d50445f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,10 @@ target data -staging \ No newline at end of file +staging +frontend/node_modules +frontend/dist +frontend/build.zip +frontend/build.zip.sha1 +frontend/playwright-report +frontend/test-results +frontend/storybook-static diff --git a/.github/workflows/build-push-edge-debug.yaml b/.github/workflows/build-push-edge-debug.yaml index 5fd649a1d..b48f71b17 100644 --- a/.github/workflows/build-push-edge-debug.yaml +++ b/.github/workflows/build-push-edge-debug.yaml @@ -41,6 +41,7 @@ jobs: - name: Clean up builder cache run: docker builder prune -f + # The Dockerfile builds frontend/ with Node 24 before embedding it in Rust. - name: Build and push uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/build-push-edge-kafka.yaml b/.github/workflows/build-push-edge-kafka.yaml index a53693e30..cfc3b2f36 100644 --- a/.github/workflows/build-push-edge-kafka.yaml +++ b/.github/workflows/build-push-edge-kafka.yaml @@ -41,6 +41,7 @@ jobs: - name: Clean up builder cache run: docker builder prune -f + # The Dockerfile builds frontend/ with Node 24 before embedding it in Rust. - name: Build and push x86_64 uses: docker/build-push-action@v6 with: @@ -52,6 +53,7 @@ jobs: build-args: | LIB_DIR=x86_64-linux-gnu + # The Dockerfile builds frontend/ with Node 24 before embedding it in Rust. - name: Build and push aarch64 uses: docker/build-push-action@v6 with: @@ -61,4 +63,4 @@ jobs: tags: quay.io/parseablehq/parseable:edge-kafka-arm64 platforms: linux/arm64 build-args: | - LIB_DIR=aarch64-linux-gnu \ No newline at end of file + LIB_DIR=aarch64-linux-gnu diff --git a/.github/workflows/build-push-edge.yaml b/.github/workflows/build-push-edge.yaml index be35f14fa..c0c66c48e 100644 --- a/.github/workflows/build-push-edge.yaml +++ b/.github/workflows/build-push-edge.yaml @@ -41,6 +41,7 @@ jobs: - name: Clean up builder cache run: docker builder prune -f + # The Dockerfile builds frontend/ with Node 24 before embedding it in Rust. - name: Build and push uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f05cd8b73..35fccba9e 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,10 @@ on: - assets/** - "**.md" +# Cross preserves this absolute workspace path inside its build container. +env: + NEXT_ASSETS_PATH: ${{ github.workspace }}/frontend/dist + jobs: # Default build without Kafka build-default: @@ -38,6 +42,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Setup Rust toolchain uses: dtolnay/rust-toolchain@stable with: @@ -51,7 +66,7 @@ jobs: - name: Install cross if: matrix.use_cross - run: cargo install cross --git https://github.com/cross-rs/cross + run: cargo install cross --git https://github.com/cross-rs/cross --locked - name: Cache dependencies uses: actions/cache@v4 @@ -82,6 +97,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install Linux dependencies run: | sudo apt-get update @@ -144,6 +170,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install macOS dependencies run: | brew install \ diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index bdc41aef6..86845cbbe 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -7,6 +7,10 @@ on: - "**.md" name: Lint, Test and Coverage Report +# Cross preserves this absolute workspace path inside its build container. +env: + NEXT_ASSETS_PATH: ${{ github.workspace }}/frontend/dist + jobs: community-ui: runs-on: ubuntu-latest @@ -22,6 +26,17 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build - uses: dtolnay/rust-toolchain@stable with: components: clippy diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml new file mode 100644 index 000000000..2e13d7116 --- /dev/null +++ b/.github/workflows/frontend.yaml @@ -0,0 +1,40 @@ +name: Frontend +on: + pull_request: + paths: ['frontend/**', 'build.rs', '.github/workflows/frontend.yaml'] + push: + paths: ['frontend/**', 'build.rs', '.github/workflows/frontend.yaml'] +permissions: + contents: read +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci && npm run build + - run: npm run package + - name: Upload frontend package + uses: actions/upload-artifact@v4 + with: + name: parseable-frontend + path: | + frontend/build.zip + frontend/build.zip.sha1 + if-no-files-found: error + - run: npx playwright install --with-deps chromium + - run: npm run check + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: frontend-browser-results + path: | + frontend/playwright-report + frontend/test-results diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4b5a639fa..e40ce3857 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,54 @@ on: - "assets/**" - "**.md" +# Cross preserves this absolute workspace path inside its build container. +env: + NEXT_ASSETS_PATH: ${{ github.workspace }}/frontend/dist + jobs: + build-frontend: + name: Package frontend + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + + - name: Package frontend + working-directory: frontend + run: npm run package + + - name: Upload frontend artifact + uses: actions/upload-artifact@v4 + with: + name: parseable-frontend + path: | + frontend/build.zip + frontend/build.zip.sha1 + if-no-files-found: error + + - name: Attach frontend to release + uses: softprops/action-gh-release@v0.1.15 + with: + draft: false + files: | + frontend/build.zip + frontend/build.zip.sha1 + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + build-linux: name: Build for ${{matrix.target}} runs-on: self-hosted @@ -35,6 +82,18 @@ jobs: use-cross: true steps: - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - uses: actions-rs/toolchain@v1 with: toolchain: stable @@ -42,11 +101,17 @@ jobs: target: ${{matrix.target}} override: true - - uses: actions-rs/cargo@v1 - with: - use-cross: ${{ matrix.use-cross }} - command: build - args: --profile release-lto --target ${{matrix.target}} + - name: Install cross + if: matrix.use-cross + run: cargo install cross --git https://github.com/cross-rs/cross --locked + + - name: Build with cross + if: matrix.use-cross + run: cross build --profile release-lto --target ${{ matrix.target }} + + - name: Build native + if: ${{ !matrix.use-cross }} + run: cargo build --profile release-lto --target ${{ matrix.target }} - name: Rename binary run: | @@ -82,6 +147,17 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install latest rust toolchain uses: actions-rs/toolchain@v1 with: @@ -132,6 +208,17 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install latest rust toolchain uses: actions-rs/toolchain@v1 with: @@ -180,6 +267,17 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install Linux dependencies run: | sudo apt-get update @@ -259,6 +357,17 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build frontend + working-directory: frontend + run: npm ci && npm run build + - name: Install macOS dependencies run: | brew install \ diff --git a/.gitignore b/.gitignore index a11e5f556..034f0d47e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ parseable_* parseable-env-secret cache .idea + +# Local credentials and review notes +*.pem +pr-*.md diff --git a/Cross.toml b/Cross.toml index 911898dde..b190ae239 100644 --- a/Cross.toml +++ b/Cross.toml @@ -1,2 +1,6 @@ +[build.env] +# Cross mounts the workspace at its original absolute path inside the container. +passthrough = ["NEXT_ASSETS_PATH"] + [target.aarch64-unknown-linux-gnu] -image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu@sha256:1e2a0291f92a4372cbc22d8994e735473045383f1ce7fa44a16c234ba00187f4" \ No newline at end of file +image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu@sha256:1e2a0291f92a4372cbc22d8994e735473045383f1ce7fa44a16c234ba00187f4" diff --git a/Dockerfile b/Dockerfile index 1be4b2fb7..95d8caba4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +# Frontend assets are independent of the target CPU architecture. +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS frontend +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + # build stage FROM rust:1.96.0-bookworm AS builder @@ -23,6 +31,8 @@ LABEL org.opencontainers.image.licenses="AGPL-3.0" WORKDIR /parseable COPY . . +COPY --from=frontend /frontend/dist /parseable/frontend/dist +ENV NEXT_ASSETS_PATH=/parseable/frontend/dist RUN cargo build --release # final stage diff --git a/Dockerfile.debug b/Dockerfile.debug index d00a04a41..e5289ea28 100644 --- a/Dockerfile.debug +++ b/Dockerfile.debug @@ -13,6 +13,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +# Frontend assets are independent of the target CPU architecture. +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS frontend +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + # build stage FROM docker.io/rust:1.96.0-bookworm AS builder @@ -27,6 +35,8 @@ 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/ +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 diff --git a/Dockerfile.dev b/Dockerfile.dev index d4f5abe7e..ff153ebcf 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -13,6 +13,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +# Frontend assets are independent of the target CPU architecture. +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS frontend +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + # build stage FROM rust:1.96.0-bookworm AS builder @@ -23,10 +31,8 @@ LABEL org.opencontainers.image.licenses="AGPL-3.0" WORKDIR /parseable COPY . . - -## Ensure that you build the console assests https://github.com/parseablehq/console?tab=readme-ov-file#trophy-development-and-contributing -## Then create a dist folder in the root of the this directory and copy the contents of the console/dist folder into the dist folder -ENV LOCAL_ASSETS_PATH=/parseable/dist +COPY --from=frontend /frontend/dist /parseable/frontend/dist +ENV NEXT_ASSETS_PATH=/parseable/frontend/dist RUN cargo build --release # final stage diff --git a/Dockerfile.kafka b/Dockerfile.kafka index 9f71a286c..19dfc2d87 100644 --- a/Dockerfile.kafka +++ b/Dockerfile.kafka @@ -13,6 +13,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +# Frontend assets are independent of the target CPU architecture. +FROM --platform=$BUILDPLATFORM node:24-bookworm-slim AS frontend +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci +COPY frontend/ ./ +RUN npm run build + # build stage FROM rust:1.96.0-bookworm AS builder @@ -36,6 +44,8 @@ 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/ +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 && \ diff --git a/README.md b/README.md index 86c4e4ab6..66b1e8b42 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,25 @@ Parseable builds are attested for build provenance and integrity using the [atte ```sh gh attestation verify PATH/TO/YOUR/PARSEABLE/ARTIFACT-BINARY -R parseablehq/parseable ``` + +### 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. + +While it reaches parity, Parseable serves it beside the classic UI: the classic +UI stays at `/` and the new frontend is mounted 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: + +```sh +(cd frontend && npm ci && npm run build) +NEXT_ASSETS_PATH="$PWD/frontend/dist" cargo build --release +``` + +Then open `/next/` for the new frontend and `/` for the classic UI. See the +[frontend README](frontend/README.md#build-and-embed-in-parseable) for build +details, PowerShell instructions, and the eventual switch to `/`. diff --git a/build.rs b/build.rs index b4e872456..d140f3087 100644 --- a/build.rs +++ b/build.rs @@ -26,6 +26,7 @@ use vergen_gitcl::{ pub fn main() -> Result<()> { ui::setup()?; + ui::setup_next()?; // Init vergen Emitter::default() @@ -54,6 +55,7 @@ mod ui { 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(); @@ -67,8 +69,33 @@ mod ui { } } + /// Embeds an optional second UI, served under `/next` beside the primary UI so + /// both can be compared. Without one, `generate_next` returns no files. + 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); + if !path.join("index.html").exists() { + panic!("Directory specified in NEXT_ASSETS_PATH has no index.html") + } + 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() + } + pub fn setup() -> io::Result<()> { - println!("cargo:rerun-if-env-changed=LOCAL_ASSETS_PATH"); + println!("cargo:rerun-if-env-changed={LOCAL_ASSETS_PATH}"); + 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()); @@ -95,6 +122,7 @@ mod ui { 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 { @@ -102,15 +130,22 @@ mod ui { } } + // 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 - let url = metadata["assets-url"].as_str().unwrap(); - // See https://docs.rs/ureq/2.5.0/ureq/struct.Response.html#method.into_reader let parseable_ui_bytes = get_from_url(url) .call() @@ -150,17 +185,6 @@ mod ui { )?; resource_dir(&community_path).build()?; - if local_assets_path.is_none() { - // emit ui version for asset url - let url = url::Url::parse(url).expect("valid url"); - let ui_version = url - .path_segments() - .expect("has segemnts") - .find(|v| v.starts_with('v')) - .expect("version segement"); - println!("cargo:rustc-env=UI_VERSION={ui_version}"); - } - Ok(()) } } diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..b98fe0c72 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +build.zip +build.zip.sha1 +storybook-static/ +playwright-report/ +test-results/ +.env.local +# The repository's data* rule is for backend data, not this source directory. +!src/features/datasets/ +!src/features/datasets/** diff --git a/frontend/.prettierrc.json b/frontend/.prettierrc.json new file mode 100644 index 000000000..2fec1f28d --- /dev/null +++ b/frontend/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts new file mode 100644 index 000000000..2139a6b1e --- /dev/null +++ b/frontend/.storybook/main.ts @@ -0,0 +1,15 @@ +import type { StorybookConfig } from '@storybook/react-vite'; +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(ts|tsx)'], + framework: '@storybook/react-vite', + addons: ['@storybook/addon-docs'], + core: { disableTelemetry: true }, + async viteFinal(config) { + config.define = { + ...config.define, + 'import.meta.env.VITE_ENABLE_DEMO': JSON.stringify('true'), + }; + return config; + }, +}; +export default config; diff --git a/frontend/.storybook/preview.ts b/frontend/.storybook/preview.ts new file mode 100644 index 000000000..fa2b2b401 --- /dev/null +++ b/frontend/.storybook/preview.ts @@ -0,0 +1,5 @@ +import type { Preview } from '@storybook/react-vite'; +import '../src/styles/tokens.css'; +import '../src/styles/global.css'; +const preview: Preview = { parameters: { layout: 'padded' } }; +export default preview; diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 000000000..c34df274f --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,113 @@ +# Parseable frontend + +An editable React/TypeScript frontend and composable UI library informed by the Prism v3.2.4 component/selector research. This is a new implementation, not the original Prism source or a feature-complete replacement. No Stripe, billing, checkout, subscription, Clerk, paid upgrade prompts, or payment collection are included. Backend authorization remains authoritative. + +## Run + +Requires Node 22.12+ and npm. From this directory: + +```sh +npm ci +npm run dev +``` + +Open http://127.0.0.1:5173. The app starts in **live server** mode. Sign in with a native Parseable username/password or choose **Sign in with SSO** for the server’s configured OAuth provider. Missing or expired sessions return to login and preserve the intended local route. Passwords are not persisted. + +**Explore demo data** is available in development. Production builds hide demo controls and ignore stored demo selections unless built with `VITE_ENABLE_DEMO=true npm run build`. Demo selection lasts for the browser tab and remains visibly labeled. The component gallery stays available at `/components`; the app and Storybook browser-test configurations explicitly enable their sample workspace where needed. + +Development requests to `/api` proxy to `http://127.0.0.1:8000`. Set a different target when starting Vite: + +```sh +PARSEABLE_PROXY_TARGET=http://127.0.0.1:8080 npm run dev +``` + +The proxy preserves the browser Host so the backend can validate the same-origin login redirect. Configure the backend OAuth callback for the frontend origin (for example `http://127.0.0.1:5173/api/v1/o/code`) and allow that origin where required. Do not put credentials in the proxy URL. `/oidc-not-configured` explains missing provider setup and offers native login. A 403 stays a permission error; it never substitutes demo results. + +## Build and embed in Parseable + +While this frontend reaches parity, Parseable serves it **beside** the classic Prism UI: Prism stays at `/` and this frontend is mounted at `/next/`. Both share one origin, so one sign-in (native or OIDC) works in both. Each page has a **Compare in classic UI** link that opens the equivalent Prism page in a new tab, and a **Classic UI** sidebar group links to features not built here yet (alerts, users, roles). + +Release builds use **Node 24**. `npm run build` targets the `/next/` base path. From this directory: + +```sh +npm ci && npm run build +cd .. +NEXT_ASSETS_PATH="$PWD/frontend/dist" cargo build --release +``` + +On PowerShell, set `$env:NEXT_ASSETS_PATH = (Resolve-Path frontend/dist).Path` from the repository root before running `cargo build --release`. + +All Parseable Dockerfiles build the frontend in a Node 24 stage and set `NEXT_ASSETS_PATH`. The build/release workflows also build it before Rust, including Windows, macOS, and Kafka variants. Cross receives `NEXT_ASSETS_PATH` through `Cross.toml`; its absolute path stays inside the mounted workspace. Without `NEXT_ASSETS_PATH`, nothing is mounted at `/next`. The build script tracks changes to the assets and environment variable. Point it at `frontend/dist`, not the source directory or Storybook output. + +The classic UI at `/` is unchanged: plain Cargo builds download the Prism ZIP pinned in `Cargo.toml` and apply the community overlay, and `/api/v1/about` reports its version. `LOCAL_ASSETS_PATH` still replaces the root UI as before. + +When this frontend replaces Prism, remove `--base=/next/` from the build script, serve it at `/` via `LOCAL_ASSETS_PATH` (or a hosted ZIP pinned in `Cargo.toml`), and remove the `/next` mount, the community overlay, and the classic UI links. + +To package the already-built `dist/` (from this directory): + +```sh +npm run package +``` + +This creates `build.zip` with a top-level `dist/` directory, including `dist/index.html`, and `build.zip.sha1`. The script prints the ZIP's SHA-1 and writes the same checksum in `sha1sum -c` format. It uses sorted entries, fixed timestamps and permissions, and no extra package dependencies; identical assets produce identical ZIP bytes with the same Node/zlib version. The frontend CI uploads both files as the `parseable-frontend` artifact, and the release workflow attaches both to the GitHub release. The ZIP contains the `/next/`-based build until the switch above. + +`npm run preview` previews the production assets at `http://127.0.0.1:4173/next/`. It has no API proxy; use demo mode, or use `npm run dev`/the embedded server for API calls. + +## Working features + +- Compact workspace shell, collapsible navigation, light/dark theme, server-preferred signed-in identity, and logout. Standalone two-panel login includes password visibility and the administrator-contact password-reset dialog. +- Logs: dataset selection, preset/absolute UTC time ranges, message search, exact field filters, searchable fields and columns, bounded event distribution, sortable/wrappable paginated results, JSON/CSV export, event details, include/exclude/copy actions, and handoff to SQL. +- SQL: dataset explorer, CodeMirror editor, Ctrl/Cmd+Enter execution, time range, query errors, shared sortable/wrappable results, column selection, and JSON/CSV export. +- Datasets: search, schema inspection, explorer navigation. +- Dashboards: create, persist/reload, and confirm deletion of browser-local event-volume tiles; demo and live definitions use separate storage keys. These are not synced to the server and do not yet support arbitrary layouts or queries. +- Component gallery at `/components`, plus eight isolated Storybook stories. + +The default log query reads at most 100 rows, sorted by `p_timestamp`. Message search assumes a `message` field. The histogram represents returned rows, not total stream volume. Demo SQL intentionally supports only a small SELECT subset and rejects unsupported syntax. Full SQL requires a server. + +Metrics, traces, APM, alerts, team administration, ingestion setup, saved queries, server dashboards, virtualized tables, and original Prism pixel parity are future iterations. No unusable paid-feature navigation or upgrade controls are exposed. + +## Components and selectors + +```text +src/components/ui/ Native/Radix primitives; no fetching or feature state +src/components/explorer/ Shared table, chart, detail sheet, time picker, query states +src/features/ Small feature components and route orchestration +src/app/ Shell, navigation, connection, client context +src/lib/ Typed live client, demo adapter, safe query builder +src/styles/ Semantic theme tokens and application layout +``` + +Use `components/ui/index.ts` as the library entry. It imports primitive styles; consumers also load `styles/tokens.css`. Components are source-local, not yet published as a separate npm package. Storybook is the isolated development surface: + +```sh +npm run storybook +``` + +The implementation preserves useful recovered hooks (`#main-section`, `data-sidebar`, `sidebar-*` test IDs, `data-field-node`, `lmt-sidebar-toggle`, `add-filter-button`, `data-filter-pill-id`, `data-dialog-confirm`, `data-tile-id`). `.ui-*` and feature classes are new semantic styles; generated Radix IDs must not be hardcoded. See [the component mapping](docs/component-map.md), [API contracts](docs/api-contracts.md), [Prism comparison and remaining gaps](docs/prism-parity.md), and [validation results](docs/validation-review.md). + +## Checks + +```sh +npx playwright install chromium +npm run check +``` + +Or run checks separately: + +```sh +npm run build # TypeScript + production bundle +npm test # Client, escaping, demo query and error contracts +npm run test:ui # Browser workflows + axe accessibility smoke checks +npm run test:stories # Build Storybook and test isolated component interactions +npm run format:check +``` + +To use an installed Chromium, set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium`. The default app suite uses isolated contexts, sample data and mocked HTTP. The separate live suite is opt-in and writes a small synthetic dataset (and an OIDC test reader role when enabled) to the explicitly supplied server: + +```sh +PARSEABLE_LIVE_URL=http://127.0.0.1:8270 \ +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ +npm run test:live +``` + +Set `PARSEABLE_LIVE_USERNAME`, `PARSEABLE_LIVE_PASSWORD` and `PARSEABLE_LIVE_DATASET` for a disposable server; defaults are `frontend-smoke`, `local-smoke-password` and `frontend_smoke`. Page paths use the `/next` mount of a server built with `NEXT_ASSETS_PATH`; set `PARSEABLE_LIVE_BASE=` (empty) when `PARSEABLE_LIVE_URL` points at `npm run dev` or another server at the root. API calls always go to the origin root. Omitting `PARSEABLE_LIVE_URL` skips the live tests. Add `PARSEABLE_LIVE_OIDC=true` only with the local fixture provider configured, or `PARSEABLE_LIVE_NO_OIDC=true` with a provider-free server to exercise the real missing-provider route. `npm run check` excludes this suite. The completed default check passed 36 unit, 33 app browser and 14 Storybook browser tests. The final live run passed seven tests with the provider-free case skipped; that case passed separately. See [the live runbook and recorded results](docs/validation-review.md) for setup, exact commands, scope and limitations. diff --git a/frontend/docs/api-contracts.md b/frontend/docs/api-contracts.md new file mode 100644 index 000000000..5dc4f99b8 --- /dev/null +++ b/frontend/docs/api-contracts.md @@ -0,0 +1,43 @@ +# Frontend API contracts + +The client is React independent and uses the Rust handlers in this repository. `createClient({mode: 'live'})` uses same-origin `/api/v1` requests with `credentials: 'include'`. The Vite development proxy must preserve the frontend origin in `Host`, or the backend must explicitly allow that origin, because the login handler validates the absolute redirect URL. + +| Method | HTTP endpoint | Response | +| ----------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `listDatasets(signal?)` | `GET /api/v1/logstream` | `[{name}]`; normalized to `{name,type:'logs'}` | +| `schema(dataset, signal?)` | `GET /api/v1/logstream/{encoded name}/schema` | Arrow schema `{fields:[{name,...}],metadata:...}`; returns top-level field names | +| `query({sql,startTime,endTime}, signal?)` | `POST /api/v1/query` | JSON array of records | +| `login(username,password,returnPath?)` | `GET /api/v1/o/login?redirect=` | Basic authorization is exchanged for backend session cookies, then a separate cookie-authenticated stream list request verifies the session (403 is retained as an authenticated permission limitation) | +| `logout()` | `GET /api/v1/o/logout?redirect=/login` | Top-level browser navigation; backend invalidates the local session and may redirect OAuth users through the provider logout endpoint | + +`identity(signal?)` requests `GET /api/v1/users/{encoded user_id}` after reading the display cookie as an identity lookup key. The server response (`id`, `username`, optional `method` and `email`) takes precedence. An unavailable profile falls back to the display-only `username`/`user_id` cookies, except that 401 and cancellation propagate. The fallback never claims to be a verified server identity. Missing cookies produce no identity request. Neither cookie content nor the identity label grants permissions; protected backend requests remain authoritative. The endpoint is declared by `get_users_webscope` and handled by `get_prism_user` in `src/handlers/http/rbac.rs`. + +## Authentication and session behavior + +Native login sends UTF-8 username/password Basic credentials only to the login endpoint, then uses cookies for data requests. Passwords are cleared from the form on success or dismissal and are never persisted or reused for queries. The redirect origin must satisfy `is_valid_redirect_url` in the current Rust handler. Frontend return paths accept local application paths and reject external/protocol-relative destinations, backslashes, control characters and authentication/API routes. + +**Sign in with SSO** performs top-level navigation to `/api/v1/o/login?redirect=`. No credential fetch follows the identity provider. The backend owns authorization, provider discovery, callback verification and cookie creation through `/api/v1/o/code`. With no configured provider, it redirects to `/oidc-not-configured`; the frontend explains this state and offers native sign-in. The client stores the intended local path, selects live mode in session storage, and clears stale browser session/display cookies before starting SSO. Selecting live mode prevents an existing demo tab from reopening sample data after a successful provider callback. + +When no server identity is available, a stream-list request also probes the session on pages that contain only local content. A live data or identity request returning 401 clears the displayed identity, removes stale local cookie hints and opens login with the intended route retained. A 403 stays a permission-denied state; neither response offers sample results. Network/server errors remain visible request failures. A 403 from the post-login stream-list check does not reject otherwise successful native login, because a restricted account can lack list permission. + +Logout uses browser navigation to `/api/v1/o/logout?redirect=/login`. This checkout removes the backend session and, for OAuth users when configured, redirects to the identity provider logout endpoint before the requested return. Native logout returns directly. The handler currently leaves browser cookies in place; subsequent 401 handling and a new SSO attempt clear stale cookies. The frontend does not claim that provider logout completed merely because the local navigation started. This differs from the previously deployed overlay described in the supplied wiki; that deployment's behavior is not this checkout's contract. + +## Query behavior + +Query bodies use `{query: sql, startTime, endTime, sendNull: true}`. We deliberately omit the `fields` and streaming query flags, so the response is an unwrapped JSON array, including for aggregate SQL. `ApiError` preserves HTTP status, the backend error message and response detail. Network and cancellation errors remain native errors. Malformed successful payloads raise an explicit contract error. + +References: `src/handlers/http/modal/server.rs` (routes), `src/handlers/http/logstream.rs` (list/schema), `src/handlers/http/query.rs` (query serialization), `src/handlers/http/oidc.rs` (native login/logout and redirect validation). + +## Current limits + +- The stream list returns names without telemetry type. The initial live client labels entries as logs; it does not guess type from stream names. A future typed inventory can use the Prism metadata API. +- Search targets a `message` field using a literal case-insensitive substring. Other schemas should use field filters or SQL. Sort order defaults to `p_timestamp`; datasets with a different time partition field need explicit SQL until metadata support is added. +- Filters quote fields and string literals, escape search wildcards, and only allow `=`/`!=`. SQL editor text is sent to the backend SQL engine and its authorization checks; there is no browser-side SQL security boundary. +- Native and OAuth sign-in use the existing backend authorization behavior. This frontend does not implement the separate OIDC group/provenance changes described in the supplied deployment wiki. Clerk, Stripe, billing and payment collection are absent. +- Mock HTTP contract tests verify frontend request shape and failure handling. The opt-in `e2e-live` suite exercises a disposable real backend and local mock identity provider; see [validation-review.md](validation-review.md) for actual completed runs. A mock provider does not establish interoperability with a real deployment or identity provider. + +## Demo data + +Demo mode is enabled only during Vite development or when built with `VITE_ENABLE_DEMO=true`; ordinary production builds ignore a previously stored demo choice. Demo mode makes no network calls and does not authenticate users. It provides three sample datasets with the same schema and the same deterministic 100-row fixture sequence. Times are anchored when the client is created so the initial 15-minute view contains useful data; values and relative ordering are deterministic. Refresh does not simulate ingestion. + +The demo SQL evaluator intentionally accepts only `SELECT * FROM ` with optional `AND` filters (`=`/`!=`/`<>`), the query builder's `CAST(field AS VARCHAR) ILIKE ... ESCAPE ...` search expression, one `ORDER BY` column, and `LIMIT`. It applies the requested timestamp bounds. It rejects unsupported SQL, aggregate expressions, joins, projections, OR conditions and multiple statements with an explanation instead of returning misleading results. Use a live backend for full SQL. diff --git a/frontend/docs/component-design-review.md b/frontend/docs/component-design-review.md new file mode 100644 index 000000000..7ec2e0a00 --- /dev/null +++ b/frontend/docs/component-design-review.md @@ -0,0 +1,37 @@ +# Component design review + +This library is a new implementation informed by the Prism v3.2.4 selector atlas. The atlas is evidence for boundaries and visual intent; it does not recover original component source or establish pixel parity. + +## Composition + +`Button`, `Input`, `Select`, `Badge`, `Card`, and `Spinner` are small native-element primitives. `Field` owns label/help/error relationships, shared by inputs and selects. `Dialog` and `Sheet` share one modal frame while their feature-owned children implement forms, details, and actions. `Tabs` wraps Radix keyboard behavior and accepts content rather than importing features. `EmptyState` accepts any action and decorative icon. + +Native element props and refs pass through the native primitives. Feature code owns request state, domain validation, routing, and persistence. Do not add network requests or feature-specific conditions to these components. `Card` deliberately imposes neither padding nor headings so charts, tables, and forms can compose their own regions. + +The library uses local CSS variables and semantic classes. `--color-primary` preserves the recovered `#3a3a8c` light-theme brand. `data-theme="dark"` on the document element switches colors, including portaled dialogs. Font stacks name Inter and JetBrains Mono but fall back locally; no external font request is required. The library has no billing, subscription, checkout, payment SDK, or entitlement UI. + +## Stable boundaries + +| Boundary | Hooks | Ownership | +| -------- | ------------------------------------------------------------------------------ | ----------------------------- | +| Button | `.ui-button`, `data-variant`, `data-size` | App primitive | +| Field | `.ui-field`, `data-invalid`, label `for`, `aria-describedby` | App primitive | +| Status | `.ui-badge`, `data-tone` | App primitive | +| Modal | `role=dialog`, `[data-dialog-close]`, optional feature `[data-dialog-confirm]` | Radix behavior, app structure | +| Sheet | `.ui-sheet`, `data-side=right` | App modal composition | +| Tabs | `role=tablist/tab/tabpanel`, `data-state=active` | Radix behavior | + +Prefer accessible role/name in interaction tests. Add feature hooks from the atlas to their actual owners, e.g. `[data-filter-pill-id]` to a filter pill and `[data-tile-id]` to a dashboard tile. Generated field and Radix IDs are relationships, not persistent selectors. The new semantic class names are not claimed to be recovered Prism classes. + +## Accessibility review + +- Buttons default to `type=button`, preventing accidental form submissions. Feature submit buttons must explicitly use `type=submit`. Icon-only consumers must supply a meaningful `aria-label`. +- Inputs and selects connect visible labels to unique IDs; help and error text connects through `aria-describedby`. Error text has `role=alert`, and invalid controls expose `aria-invalid`. Caller-provided description IDs remain intact. +- The native select retains platform keyboard and mobile behavior. The decorative chevron cannot intercept input or enter the accessibility tree. +- Radix supplies modal focus containment, Escape dismissal, semantic title/description relationships, and background interaction blocking. The frame remembers the focused opener and restores focus on dismissal even when the opener sits outside a Radix trigger. Close controls are named. Modal children remain responsible for labeling all inputs and for validation and submit outcomes. +- Radix Tabs supplies arrow-key navigation, selection state, and tab/panel relationships. Every use should supply a context-specific tab-list label. +- Focus indicators are visible. Status badges contain text so meaning does not depend on color. Spinner provides an accessible loading status. Motion effects stop with `prefers-reduced-motion`. + +## Validation and remaining work + +The component gallery exercises variants, disabled controls, labeled fields, error state, modal and sheet content, tab content, empty state, and loading. Treat it as an inspectable starting point. Validate representative screens at narrow widths and with keyboard-only navigation, both color themes, zoom, and realistic long content. Authenticated original Logs, initial SQL, datasets and dashboard-list states now have runtime comparisons; see [current parity evidence](prism-parity.md) and [completed validation](validation-review.md). Uncaptured interactions still need comparison. This review is not a full accessibility conformance audit. diff --git a/frontend/docs/component-map.md b/frontend/docs/component-map.md new file mode 100644 index 000000000..4d19eb22f --- /dev/null +++ b/frontend/docs/component-map.md @@ -0,0 +1,38 @@ +# From the recovered UI to editable components + +The reference is the repository-pinned Prism v3.2.4 archive (SHA-1 `5e3ec81fbb35b2b8aa6486a2bd38257d46b0264b`). These are proposed and implemented boundaries; original TypeScript names/source were not recovered. + +| Recovered concept / hook | Editable implementation | Responsibility | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| Sidebar family, `data-sidebar`, `sidebar-*` | `app/Sidebar.tsx`, `app/App.tsx` | Navigation, active routes, collapse, workspace context | +| `#main-section`, breadcrumb | `app/App.tsx` | Route outlet and shell | +| Button, Input, Badge, Card | `components/ui/` | Native semantics, appearance, forwarded props | +| Radix Dialog/Sheet, `data-dialog-close`, `data-dialog-confirm` | `components/ui/Dialog.tsx`, `Sheet.tsx` | Modal anatomy, title, focus containment/restoration; feature-owned forms | +| Tabs | `components/ui/Tabs.tsx` | Keyboard navigation and panel relationships | +| LMTSidebar, `data-field-node`, `lmt-sidebar-toggle` | `features/logs/FieldSidebar.tsx` | Searchable fields and visible columns; keep at least one column | +| Filter, `add-filter-button`, `data-filter-pill-id`, `filter-pill-value` | `features/logs/FilterBar.tsx` | Exact-match filter form and removable chips | +| TimeRange | `components/explorer/TimeRangePicker.tsx` | Preset and absolute UTC ranges; timezone selection remains deferred | +| LogCountChartCard | `components/explorer/LogHistogram.tsx` | Dependency-light histogram of returned rows; no invented totals | +| Table/row/context, `data-index` | `components/explorer/DataTable.tsx`, `RecordSheet.tsx` | Sortable/wrappable paginated records, JSON/CSV export, per-event fields and copy/filter actions | +| CodeMirror SQL module | `features/sql/SqlEditor.tsx` | Editor lifecycle, accessible name, syntax colors, run shortcut | +| Dataset listing/schema | `features/datasets/DatasetsPage.tsx` | Inventory, search, schema sheet, navigation | +| Dashboard tile, `data-tile-id` | `features/dashboards/DashboardTile.tsx` | Query-backed chart tile, stable local identity | +| EmptyState/PageError/loading | `components/ui/EmptyState.tsx`, `Spinner.tsx`, `components/explorer/QueryState.tsx` | Distinct empty, loading and recoverable failure states | + +The current iteration adds these explicit owners: + +| Recovered concept / hook | Editable implementation | Responsibility | +| ------------------------------------------ | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Observed standalone login and reset dialog | `app/LoginPage.tsx`, `app/LoginForm.tsx`, `lib/auth.ts` | Split login layout, password visibility, reset guidance, safe return path and native/OAuth navigation | +| Column selection / “Add columns” | `components/explorer/ColumnPicker.tsx` | Searchable visible-column controls, independently composed by Logs and SQL | +| `Sort {column}`, wrapping and exports | `components/explorer/DataTable.tsx` | Accessible sorting, bounded pagination, summary rows, wrapping and JSON/CSV serialization | + +Authenticated reference screenshots and completed real-backend checks are recorded in [Prism parity](prism-parity.md) and [validation review](validation-review.md). + +Styling uses semantic CSS custom properties and small named classes instead of copying compiled Tailwind strings. The primary light-theme color `#3a3a8c` follows the recovered tokens. Fonts use local system fallbacks for Inter and JetBrains Mono; the app does not fetch fonts from a third party. New React 19 and current build packages were selected for editable development, not to claim exact runtime-version parity with the old bundle. + +Feature state belongs in route containers. UI primitives do not import the client. Tables do not fetch data. Charts do not decide query ranges. The React-independent client normalizes backend responses and isolates sample behavior. `QueryState` is an app-aware composite, not a portable primitive. + +Future features should extend shared controls first and compose their own query/result surfaces. Prefer stable record/model identifiers to positional selectors when backend identities become available. The current `data-index` is a table position, not event identity. Browser-local dashboard IDs are model IDs and must not be replaced with array indices. + +Payment-dependent cloud flows and upsell surfaces from the original bundle were intentionally not carried over. No extracted minified code, credential-like literals, or copied cloud SDK configuration is part of this source. diff --git a/frontend/docs/parity-screenshots/dashboards-comparison.png b/frontend/docs/parity-screenshots/dashboards-comparison.png new file mode 100644 index 000000000..edc4125e7 Binary files /dev/null and b/frontend/docs/parity-screenshots/dashboards-comparison.png differ diff --git a/frontend/docs/parity-screenshots/editable-dashboards.json b/frontend/docs/parity-screenshots/editable-dashboards.json new file mode 100644 index 000000000..65bedbcbb --- /dev/null +++ b/frontend/docs/parity-screenshots/editable-dashboards.json @@ -0,0 +1,356 @@ +{ + "url": "http://127.0.0.1:8270/dashboards", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Skip to content\nOverview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar\nCommunity\nDashboards\nLive server\nF\nfrontend-smoke\nDashboards\n\nBrowser-local dashboards\n\nCreate dashboard\n0 dashboards\nSaved in this browser only. Each tile shows up to 100 returned events from the last hour.\nA clear view starts here\n\nCreate a dashboard to keep an eye on event volume for a dataset.\n\nCreate your first dashboard", + "issues": [], + "boxes": [ + { + "tag": "ASIDE", + "hook": "sidebar", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "header", + "text": "", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "NAV", + "hook": "content", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 352, + "top": 98, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nDashboards\nSQL editor", + "box": { + "x": 8, + "y": 131, + "width": 175, + "height": 97, + "top": 131, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "ANALYZE", + "box": { + "x": 16, + "y": 149, + "width": 159, + "height": 15, + "top": 149, + "right": 175, + "bottom": 164, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 172, + "width": 175, + "height": 28, + "top": 172, + "right": 183, + "bottom": 200, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "SQL editor", + "box": { + "x": 8, + "y": 200, + "width": 175, + "height": 28, + "top": 200, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs", + "box": { + "x": 8, + "y": 233, + "width": 175, + "height": 69, + "top": 233, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "OBSERVE", + "box": { + "x": 16, + "y": 251, + "width": 159, + "height": 15, + "top": 251, + "right": 175, + "bottom": 266, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 274, + "width": 175, + "height": 28, + "top": 274, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 8, + "y": 307, + "width": 175, + "height": 69, + "top": 307, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DATA", + "box": { + "x": 16, + "y": 325, + "width": 159, + "height": 15, + "top": 325, + "right": 175, + "bottom": 340, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 348, + "width": 175, + "height": 28, + "top": 348, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DEVELOP\nComponent library", + "box": { + "x": 8, + "y": 381, + "width": 175, + "height": 69, + "top": 381, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DEVELOP", + "box": { + "x": 16, + "y": 399, + "width": 159, + "height": 15, + "top": 399, + "right": 175, + "bottom": 414, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Component library", + "box": { + "x": 8, + "y": 422, + "width": 175, + "height": 28, + "top": 422, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "footer", + "text": "Connection\nCollapse sidebar", + "box": { + "x": 8, + "y": 883, + "width": 175, + "height": 109, + "top": 883, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "trigger", + "text": "Collapse sidebar", + "box": { + "x": 8, + "y": 956, + "width": 175, + "height": 36, + "top": 956, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "HEADER", + "hook": "", + "text": "Community\nDashboards\nLive server\nF\nfrontend-smoke", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Dashboards\n\nBrowser-local dashboards\n\nCreate dashboard\n0 dashboards\nSaved in this browser only. Each tile shows up to 10", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 502.890625, + "top": 48, + "right": 1440, + "bottom": 550.890625, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "Dashboards", + "box": { + "x": 212, + "y": 68, + "width": 138.734375, + "height": 26, + "top": 68, + "right": 350.734375, + "bottom": 94, + "left": 212 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/editable-dashboards.png b/frontend/docs/parity-screenshots/editable-dashboards.png new file mode 100644 index 000000000..f1164ff46 Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-dashboards.png differ diff --git a/frontend/docs/parity-screenshots/editable-datasets.json b/frontend/docs/parity-screenshots/editable-datasets.json new file mode 100644 index 000000000..83447cec1 --- /dev/null +++ b/frontend/docs/parity-screenshots/editable-datasets.json @@ -0,0 +1,356 @@ +{ + "url": "http://127.0.0.1:8270/datasets", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Skip to content\nOverview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar\nCommunity\nDatasets\nLive server\nF\nfrontend-smoke\nDatasets\n\nQueryable containers where your logs, metrics, or traces live.\n\nRefresh\n2 datasets\nDatasets\nName\tType\tActions\n\npstats\tlogs\t\nView schema\nExplore\n\n\nfrontend_smoke\tlogs\t\nView schema\nExplore", + "issues": [], + "boxes": [ + { + "tag": "ASIDE", + "hook": "sidebar", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "header", + "text": "", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "NAV", + "hook": "content", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 352, + "top": 98, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nDashboards\nSQL editor", + "box": { + "x": 8, + "y": 131, + "width": 175, + "height": 97, + "top": 131, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "ANALYZE", + "box": { + "x": 16, + "y": 149, + "width": 159, + "height": 15, + "top": 149, + "right": 175, + "bottom": 164, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 172, + "width": 175, + "height": 28, + "top": 172, + "right": 183, + "bottom": 200, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "SQL editor", + "box": { + "x": 8, + "y": 200, + "width": 175, + "height": 28, + "top": 200, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs", + "box": { + "x": 8, + "y": 233, + "width": 175, + "height": 69, + "top": 233, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "OBSERVE", + "box": { + "x": 16, + "y": 251, + "width": 159, + "height": 15, + "top": 251, + "right": 175, + "bottom": 266, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 274, + "width": 175, + "height": 28, + "top": 274, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 8, + "y": 307, + "width": 175, + "height": 69, + "top": 307, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DATA", + "box": { + "x": 16, + "y": 325, + "width": 159, + "height": 15, + "top": 325, + "right": 175, + "bottom": 340, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 348, + "width": 175, + "height": 28, + "top": 348, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DEVELOP\nComponent library", + "box": { + "x": 8, + "y": 381, + "width": 175, + "height": 69, + "top": 381, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DEVELOP", + "box": { + "x": 16, + "y": 399, + "width": 159, + "height": 15, + "top": 399, + "right": 175, + "bottom": 414, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Component library", + "box": { + "x": 8, + "y": 422, + "width": 175, + "height": 28, + "top": 422, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "footer", + "text": "Connection\nCollapse sidebar", + "box": { + "x": 8, + "y": 883, + "width": 175, + "height": 109, + "top": 883, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "trigger", + "text": "Collapse sidebar", + "box": { + "x": 8, + "y": 956, + "width": 175, + "height": 36, + "top": 956, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "HEADER", + "hook": "", + "text": "Community\nDatasets\nLive server\nF\nfrontend-smoke", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Datasets\n\nQueryable containers where your logs, metrics, or traces live.\n\nRefresh\n2 datasets\nDatasets\nName\tType\tActions\n", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 313, + "top": 48, + "right": 1440, + "bottom": 361, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "Datasets", + "box": { + "x": 212, + "y": 68, + "width": 325.46875, + "height": 26, + "top": 68, + "right": 537.46875, + "bottom": 94, + "left": 212 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/editable-datasets.png b/frontend/docs/parity-screenshots/editable-datasets.png new file mode 100644 index 000000000..e62ae0448 Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-datasets.png differ diff --git a/frontend/docs/parity-screenshots/editable-login-mobile.png b/frontend/docs/parity-screenshots/editable-login-mobile.png new file mode 100644 index 000000000..95a4cbe8e Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-login-mobile.png differ diff --git a/frontend/docs/parity-screenshots/editable-login.json b/frontend/docs/parity-screenshots/editable-login.json new file mode 100644 index 000000000..195eeff65 --- /dev/null +++ b/frontend/docs/parity-screenshots/editable-login.json @@ -0,0 +1,134 @@ +{ + "url": "http://127.0.0.1:8270/login", + "text": "parseable\nObservability\nSimplified.\n\nAI Native observability datalake.\n\nPetascale ingestion\nNatural language interface\nNative OpenTelemetry support\nBlazing fast query engine\nUp to 90% compression with columnar design\n\nSelf-hosted workspace · Your infrastructure. Your data.\n\nLog in to your account\nUsername\nPassword\nForgot password?\nLogin\nOR WITH OAUTH\nSign in with SSO\nExplore demo data", + "boxes": [ + { + "tag": "FORM", + "name": null, + "text": "Username\nPassword\nForgot password?\nLogin\nOR WITH OAUTH\nSign in with SSO", + "ariaLabel": "Sign in to Parseable", + "box": { + "x": 784, + "y": 313.953125, + "width": 448, + "height": 376.09375, + "top": 313.953125, + "right": 1232, + "bottom": 690.046875, + "left": 784 + } + }, + { + "tag": "INPUT", + "name": "username", + "text": "", + "ariaLabel": null, + "box": { + "x": 784, + "y": 336.75, + "width": 448, + "height": 40, + "top": 336.75, + "right": 1232, + "bottom": 376.75, + "left": 784 + } + }, + { + "tag": "INPUT", + "name": "password", + "text": "", + "ariaLabel": null, + "box": { + "x": 784, + "y": 419.546875, + "width": 448, + "height": 40, + "top": 419.546875, + "right": 1232, + "bottom": 459.546875, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "", + "ariaLabel": "Show password", + "box": { + "x": 1194, + "y": 422.546875, + "width": 34, + "height": 34, + "top": 422.546875, + "right": 1228, + "bottom": 456.546875, + "left": 1194 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Forgot password?", + "ariaLabel": null, + "box": { + "x": 784, + "y": 479.546875, + "width": 124.125, + "height": 22, + "top": 479.546875, + "right": 908.125, + "bottom": 501.546875, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Login", + "ariaLabel": null, + "box": { + "x": 784, + "y": 521.546875, + "width": 448, + "height": 40, + "top": 521.546875, + "right": 1232, + "bottom": 561.546875, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Sign in with SSO", + "ariaLabel": null, + "box": { + "x": 784, + "y": 650.046875, + "width": 448, + "height": 40, + "top": 650.046875, + "right": 1232, + "bottom": 690.046875, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Explore demo data", + "ariaLabel": null, + "box": { + "x": 784, + "y": 730.046875, + "width": 448, + "height": 46, + "top": 730.046875, + "right": 1232, + "bottom": 776.046875, + "left": 784 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/editable-login.png b/frontend/docs/parity-screenshots/editable-login.png new file mode 100644 index 000000000..1509aa63a Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-login.png differ diff --git a/frontend/docs/parity-screenshots/editable-logs-mobile.png b/frontend/docs/parity-screenshots/editable-logs-mobile.png new file mode 100644 index 000000000..e87a58cda Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-logs-mobile.png differ diff --git a/frontend/docs/parity-screenshots/editable-logs.json b/frontend/docs/parity-screenshots/editable-logs.json new file mode 100644 index 000000000..e207dc4d8 --- /dev/null +++ b/frontend/docs/parity-screenshots/editable-logs.json @@ -0,0 +1,431 @@ +{ + "url": "http://127.0.0.1:8270/logs/explore/frontend_smoke", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Skip to content\nOverview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar\nCommunity\nLogs\npstats\nfrontend_smoke\nLive server\nF\nfrontend-smoke\nLogs\nAll Fields\n7\nlevel\n+\nmessage\n+\np_src_ip\n+\np_timestamp\n✓\np_user_agent\n+\nrequest_id\n+\nservice\n+\nLast 15 minutes\nLast 1 hour\nLast 6 hours\nLast 24 hours\nLast 7 days\nCustom range\nAdd filter\nLogs over time\n12 returned events\n1:05:28 PM\nReturned rows · UTC\n2:05:28 PM\n12 records\nHide histogram\nAdd columns\nExport JSON\nExport CSV\nQuery results\nDetails\n\t\nIngestion Time (UTC)\n\tData\n\n\t2026-10-07 14:05:05.232\tlevel=WARN message=Live smoke retry scheduled p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-3 service=frontend-smoke\n\n\t2026-10-07 14:05:05.232\tlevel=ERROR message=Live smoke payment timeout p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-2 service=frontend-smoke\n\n\t2026-10-07 14:05:05.232\tlevel=INFO message=Live smoke request accepted p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-1 service=frontend-smoke\n\n\t2026-10-07 14:05:05.232\tlevel=INFO message=Live smoke request complete p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-4 service=frontend-smoke\n\n\t2026-10-07 14:02:12.607\tlevel=WARN message=Live smoke retry scheduled p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-3 service=frontend-smoke\n\n\t2026-10-07 14:02:12.607\tlevel=ERROR message=Live smoke payment timeout p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-2 service=frontend-smoke\n\n\t2026-10-07 14:02:12.607\tlevel=INFO message=Live smoke request accepted p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-1 service=frontend-smoke\n\n\t2026-10-07 14:02:12.607\tlevel=INFO message=Live smoke request complete p_src_ip=127.0.0.1 p_user_agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36 request_id=smoke-4 service=frontend-smoke\n\n\t2026-10-07 14:00:44.985\tlevel=WARN message=Live smoke retry scheduled p_src_ip=127.0.0.1 p_user_agent=Python-urllib/3.14 request_id=smoke-3 service=frontend-smoke\n\n\t2026-10-07 14:00:44.985\tlevel=ERROR message=Live smoke payment timeout p_src_ip=127.0.0.1 p_user_agent=Python-urllib/3.14 request_id=smoke-2 service=frontend-smoke\n\n\t2026-10-07 14:00:44.985\tlevel=INFO message=Live smoke request accepted p_src_ip=127.0.0.1 p_user_agent=Python-urllib/3.14 request_id=smoke-1 service=frontend-smoke\n\n\t2026-10-07 14:00:44.985\tlevel=INFO message=Live smoke request complete p_src_ip=127.0.0.1 p_user_agent=Python-urllib/3.14 request_id=smoke-4 service=frontend-smoke\n12 rows · 1–12\nPage 1 of 1", + "issues": [], + "boxes": [ + { + "tag": "ASIDE", + "hook": "sidebar", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "header", + "text": "", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "NAV", + "hook": "content", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 352, + "top": 98, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nDashboards\nSQL editor", + "box": { + "x": 8, + "y": 131, + "width": 175, + "height": 97, + "top": 131, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "ANALYZE", + "box": { + "x": 16, + "y": 149, + "width": 159, + "height": 15, + "top": 149, + "right": 175, + "bottom": 164, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 172, + "width": 175, + "height": 28, + "top": 172, + "right": 183, + "bottom": 200, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "SQL editor", + "box": { + "x": 8, + "y": 200, + "width": 175, + "height": 28, + "top": 200, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs", + "box": { + "x": 8, + "y": 233, + "width": 175, + "height": 69, + "top": 233, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "OBSERVE", + "box": { + "x": 16, + "y": 251, + "width": 159, + "height": 15, + "top": 251, + "right": 175, + "bottom": 266, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 274, + "width": 175, + "height": 28, + "top": 274, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 8, + "y": 307, + "width": 175, + "height": 69, + "top": 307, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DATA", + "box": { + "x": 16, + "y": 325, + "width": 159, + "height": 15, + "top": 325, + "right": 175, + "bottom": 340, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 348, + "width": 175, + "height": 28, + "top": 348, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DEVELOP\nComponent library", + "box": { + "x": 8, + "y": 381, + "width": 175, + "height": 69, + "top": 381, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DEVELOP", + "box": { + "x": 16, + "y": 399, + "width": 159, + "height": 15, + "top": 399, + "right": 175, + "bottom": 414, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Component library", + "box": { + "x": 8, + "y": 422, + "width": 175, + "height": 28, + "top": 422, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "footer", + "text": "Connection\nCollapse sidebar", + "box": { + "x": 8, + "y": 883, + "width": 175, + "height": 109, + "top": 883, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "trigger", + "text": "Collapse sidebar", + "box": { + "x": 8, + "y": 956, + "width": 175, + "height": 36, + "top": 956, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "HEADER", + "hook": "", + "text": "Community\nLogs\npstats\nfrontend_smoke\nLive server\nF\nfrontend-smoke", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Logs\nAll Fields\n7\nlevel\n+\nmessage\n+\np_src_ip\n+\np_timestamp\n✓\np_user_agent\n+\nrequest_id\n+\nservice\n+\nLast 15 minutes\nLast ", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "Logs", + "box": { + "x": 191, + "y": 47, + "width": 1, + "height": 1, + "top": 47, + "right": 192, + "bottom": 48, + "left": 191 + } + }, + { + "tag": "H2", + "hook": "", + "text": "All Fields", + "box": { + "x": 208, + "y": 114, + "width": 48.6875, + "height": 18, + "top": 114, + "right": 256.6875, + "bottom": 132, + "left": 208 + } + }, + { + "tag": "BUTTON", + "hook": "lmt-sidebar-toggle", + "text": "", + "box": { + "x": 448, + "y": 54.5, + "width": 34, + "height": 34, + "top": 54.5, + "right": 482, + "bottom": 88.5, + "left": 448 + } + }, + { + "tag": "BUTTON", + "hook": "add-filter-button", + "text": "Add filter", + "box": { + "x": 1283.328125, + "y": 108, + "width": 100.671875, + "height": 36, + "top": 108, + "right": 1384, + "bottom": 144, + "left": 1283.328125 + } + }, + { + "tag": "H2", + "hook": "", + "text": "Logs over time", + "box": { + "x": 448, + "y": 160, + "width": 85.359375, + "height": 18, + "top": 160, + "right": 533.359375, + "bottom": 178, + "left": 448 + } + }, + { + "tag": "H2", + "hook": "", + "text": "12 records", + "box": { + "x": 448, + "y": 315.5, + "width": 56.71875, + "height": 18, + "top": 315.5, + "right": 504.71875, + "bottom": 333.5, + "left": 448 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/editable-logs.png b/frontend/docs/parity-screenshots/editable-logs.png new file mode 100644 index 000000000..84fcca28a Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-logs.png differ diff --git a/frontend/docs/parity-screenshots/editable-sql.json b/frontend/docs/parity-screenshots/editable-sql.json new file mode 100644 index 000000000..f0e2c2a64 --- /dev/null +++ b/frontend/docs/parity-screenshots/editable-sql.json @@ -0,0 +1,401 @@ +{ + "url": "http://127.0.0.1:8270/sql-editor", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Skip to content\nOverview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar\nCommunity\nSQL editor\nLive server\nF\nfrontend-smoke\nQuery 1\nSQL editor\nLast 15 minutes\nLast 1 hour\nLast 6 hours\nLast 24 hours\nLast 7 days\nCustom range\nExplorer\n2\npstats\nfrontend_smoke\n1\n\n\nQueries run against your connected Parseable server.\nRun query\nResults\nAdd columns\nExport JSON\nExport CSV\nReady when you are\n\nRun a query to inspect your events.", + "issues": [], + "boxes": [ + { + "tag": "ASIDE", + "hook": "sidebar", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library\nConnection\nCollapse sidebar", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "header", + "text": "", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "NAV", + "hook": "content", + "text": "Overview\nANALYZE\nDashboards\nSQL editor\nOBSERVE\nLogs\nDATA\nDatasets\nDEVELOP\nComponent library", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 352, + "top": 98, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Overview", + "box": { + "x": 8, + "y": 98, + "width": 175, + "height": 28, + "top": 98, + "right": 183, + "bottom": 126, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nDashboards\nSQL editor", + "box": { + "x": 8, + "y": 131, + "width": 175, + "height": 97, + "top": 131, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "ANALYZE", + "box": { + "x": 16, + "y": 149, + "width": 159, + "height": 15, + "top": 149, + "right": 175, + "bottom": 164, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 172, + "width": 175, + "height": 28, + "top": 172, + "right": 183, + "bottom": 200, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "SQL editor", + "box": { + "x": 8, + "y": 200, + "width": 175, + "height": 28, + "top": 200, + "right": 183, + "bottom": 228, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs", + "box": { + "x": 8, + "y": 233, + "width": 175, + "height": 69, + "top": 233, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "OBSERVE", + "box": { + "x": 16, + "y": 251, + "width": 159, + "height": 15, + "top": 251, + "right": 175, + "bottom": 266, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 274, + "width": 175, + "height": 28, + "top": 274, + "right": 183, + "bottom": 302, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 8, + "y": 307, + "width": 175, + "height": 69, + "top": 307, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DATA", + "box": { + "x": 16, + "y": 325, + "width": 159, + "height": 15, + "top": 325, + "right": 175, + "bottom": 340, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 348, + "width": 175, + "height": 28, + "top": 348, + "right": 183, + "bottom": 376, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DEVELOP\nComponent library", + "box": { + "x": 8, + "y": 381, + "width": 175, + "height": 69, + "top": 381, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group-label", + "text": "DEVELOP", + "box": { + "x": 16, + "y": 399, + "width": 159, + "height": 15, + "top": 399, + "right": 175, + "bottom": 414, + "left": 16 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Component library", + "box": { + "x": 8, + "y": 422, + "width": 175, + "height": 28, + "top": 422, + "right": 183, + "bottom": 450, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "footer", + "text": "Connection\nCollapse sidebar", + "box": { + "x": 8, + "y": 883, + "width": 175, + "height": 109, + "top": 883, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "trigger", + "text": "Collapse sidebar", + "box": { + "x": 8, + "y": 956, + "width": 175, + "height": 36, + "top": 956, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "HEADER", + "hook": "", + "text": "Community\nSQL editor\nLive server\nF\nfrontend-smoke", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Query 1\nSQL editor\nLast 15 minutes\nLast 1 hour\nLast 6 hours\nLast 24 hours\nLast 7 days\nCustom range\nExplorer\n2\npstats\nfro", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "SQL editor", + "box": { + "x": 208, + "y": 104.40625, + "width": 67.8125, + "height": 18.1875, + "top": 104.40625, + "right": 275.8125, + "bottom": 122.59375, + "left": 208 + } + }, + { + "tag": "H2", + "hook": "", + "text": "Explorer", + "box": { + "x": 208, + "y": 150, + "width": 56.8125, + "height": 21, + "top": 150, + "right": 264.8125, + "bottom": 171, + "left": 208 + } + }, + { + "tag": "H2", + "hook": "", + "text": "Query", + "box": { + "x": 0, + "y": 0, + "width": 0, + "height": 0, + "top": 0, + "right": 0, + "bottom": 0, + "left": 0 + } + }, + { + "tag": "H2", + "hook": "", + "text": "Results", + "box": { + "x": 488, + "y": 446.5, + "width": 50.578125, + "height": 21, + "top": 446.5, + "right": 538.578125, + "bottom": 467.5, + "left": 488 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/editable-sql.png b/frontend/docs/parity-screenshots/editable-sql.png new file mode 100644 index 000000000..cd9107564 Binary files /dev/null and b/frontend/docs/parity-screenshots/editable-sql.png differ diff --git a/frontend/docs/parity-screenshots/login-comparison.png b/frontend/docs/parity-screenshots/login-comparison.png new file mode 100644 index 000000000..d5a0788dd Binary files /dev/null and b/frontend/docs/parity-screenshots/login-comparison.png differ diff --git a/frontend/docs/parity-screenshots/logs-comparison.png b/frontend/docs/parity-screenshots/logs-comparison.png new file mode 100644 index 000000000..0545d1e3a Binary files /dev/null and b/frontend/docs/parity-screenshots/logs-comparison.png differ diff --git a/frontend/docs/parity-screenshots/prism-dashboards.json b/frontend/docs/parity-screenshots/prism-dashboards.json new file mode 100644 index 000000000..a272f3331 --- /dev/null +++ b/frontend/docs/parity-screenshots/prism-dashboards.json @@ -0,0 +1,866 @@ +{ + "url": "http://127.0.0.1:8260/dashboards", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Community\nDashboards\nConnect MCP\nFS\nCtrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlerts\nErrors\nDATA\nDatasets\nSettings\nBook a call\nSupport\nSidebar controls\nDashboards\nNew dashboard\nGenerate dashboard\nVisualize and explore your telemetry data\nReady-made dashboards for your stack\nStart with a pre-built dashboard and customize it for your needs\nAgent Cost Analysis\nAgent\nCrewAI\nMastra Agent\nHermes Agent\nLiteLLM Proxy\nLiteLLM SDK\nModal\nMy Dashboards\nAll Dashboards\nFavorites\nTemplate Gallery\nNo Dashboards Yet\nBuild your first dashboard to get started\nCreate dashboard", + "issues": [], + "boxes": [ + { + "tag": "HEADER", + "hook": "", + "text": "Community\nDashboards\nConnect MCP\nFS", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "content", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Ctrl K", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 40, + "top": 48, + "right": 191, + "bottom": 88, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "INPUT", + "hook": "sidebar-search", + "text": "", + "box": { + "x": 41, + "y": 60, + "width": 98.546875, + "height": 28, + "top": 60, + "right": 139.546875, + "bottom": 88, + "left": 41 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Home\nIngest telemetry", + "box": { + "x": 0, + "y": 88, + "width": 191, + "height": 70, + "top": 88, + "right": 191, + "bottom": 158, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Home\nIngest telemetry", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 58, + "top": 100, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nKeystone\nDashboards\nSQL Editor", + "box": { + "x": 0, + "y": 158, + "width": 191, + "height": 126, + "top": 158, + "right": 191, + "bottom": 284, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Keystone\nDashboards\nSQL Editor", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 88, + "top": 196, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 0, + "y": 284, + "width": 191, + "height": 186, + "top": 284, + "right": 191, + "bottom": 470, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Logs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 148, + "top": 322, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "MONITOR\nAlerts\nErrors", + "box": { + "x": 0, + "y": 470, + "width": 191, + "height": 96, + "top": 470, + "right": 191, + "bottom": 566, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Alerts\nErrors", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 58, + "top": 508, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 0, + "y": 566, + "width": 191, + "height": 66, + "top": 566, + "right": 191, + "bottom": 632, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Settings\nBook a call\nSupport\nSidebar controls", + "box": { + "x": 0, + "y": 874, + "width": 191, + "height": 126, + "top": 874, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Settings\nBook a call\nSupport", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 88, + "top": 874, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Dashboards\nNew dashboard\nGenerate dashboard\nVisualize and explore your telemetry data\nReady-made dashboards for your sta", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "Dashboards", + "box": { + "x": 208, + "y": 64, + "width": 116.46875, + "height": 28, + "top": 64, + "right": 324.46875, + "bottom": 92, + "left": 208 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/prism-dashboards.png b/frontend/docs/parity-screenshots/prism-dashboards.png new file mode 100644 index 000000000..2e8512ee4 Binary files /dev/null and b/frontend/docs/parity-screenshots/prism-dashboards.png differ diff --git a/frontend/docs/parity-screenshots/prism-datasets.json b/frontend/docs/parity-screenshots/prism-datasets.json new file mode 100644 index 000000000..3188d0b09 --- /dev/null +++ b/frontend/docs/parity-screenshots/prism-datasets.json @@ -0,0 +1,866 @@ +{ + "url": "http://127.0.0.1:8260/datasets", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Community\nDatasets\nConnect MCP\nFS\nCtrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlerts\nErrors\nDATA\nDatasets\nSettings\nBook a call\nSupport\nSidebar controls\nDatasets\nDocumentation\nIngest telemetry\nQueryable containers where your logs, metrics, or traces live.\nAll Datasets\n1\nLogs\n1\nMetrics\n0\nTraces\n0\nName\n\t\nIngestion\n\t\nStorage\n\t\nIngestion in last 1 hour\n\t\nPartition Field\n\t\nRetention\n\t\n\nfrontend_smoke\nLogs (arbitrary)\n\t1.3 KiB\t\n5.2 KiB\n0%\n\t\n\t-\t-\t\nShowing 1–1 of 1 datasets\n1 / 1", + "issues": [], + "boxes": [ + { + "tag": "HEADER", + "hook": "", + "text": "Community\nDatasets\nConnect MCP\nFS", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "content", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Ctrl K", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 40, + "top": 48, + "right": 191, + "bottom": 88, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "INPUT", + "hook": "sidebar-search", + "text": "", + "box": { + "x": 41, + "y": 60, + "width": 98.546875, + "height": 28, + "top": 60, + "right": 139.546875, + "bottom": 88, + "left": 41 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Home\nIngest telemetry", + "box": { + "x": 0, + "y": 88, + "width": 191, + "height": 70, + "top": 88, + "right": 191, + "bottom": 158, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Home\nIngest telemetry", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 58, + "top": 100, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nKeystone\nDashboards\nSQL Editor", + "box": { + "x": 0, + "y": 158, + "width": 191, + "height": 126, + "top": 158, + "right": 191, + "bottom": 284, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Keystone\nDashboards\nSQL Editor", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 88, + "top": 196, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 0, + "y": 284, + "width": 191, + "height": 186, + "top": 284, + "right": 191, + "bottom": 470, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Logs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 148, + "top": 322, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "MONITOR\nAlerts\nErrors", + "box": { + "x": 0, + "y": 470, + "width": 191, + "height": 96, + "top": 470, + "right": 191, + "bottom": 566, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Alerts\nErrors", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 58, + "top": 508, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 0, + "y": 566, + "width": 191, + "height": 66, + "top": 566, + "right": 191, + "bottom": 632, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Settings\nBook a call\nSupport\nSidebar controls", + "box": { + "x": 0, + "y": 874, + "width": 191, + "height": 126, + "top": 874, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Settings\nBook a call\nSupport", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 88, + "top": 874, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Datasets\nDocumentation\nIngest telemetry\nQueryable containers where your logs, metrics, or traces live.\nAll Datasets\n1\nLo", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + }, + { + "tag": "H1", + "hook": "", + "text": "Datasets", + "box": { + "x": 208, + "y": 64, + "width": 85.28125, + "height": 28, + "top": 64, + "right": 293.28125, + "bottom": 92, + "left": 208 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/prism-datasets.png b/frontend/docs/parity-screenshots/prism-datasets.png new file mode 100644 index 000000000..1633f2e43 Binary files /dev/null and b/frontend/docs/parity-screenshots/prism-datasets.png differ diff --git a/frontend/docs/parity-screenshots/prism-login.json b/frontend/docs/parity-screenshots/prism-login.json new file mode 100644 index 000000000..2bb557bf6 --- /dev/null +++ b/frontend/docs/parity-screenshots/prism-login.json @@ -0,0 +1,118 @@ +{ + "url": "http://127.0.0.1:8260/login?next=%2F", + "text": "Observability\nSimplified.\nAI Native observability datalake.\nPetascale ingestion\nNatural language interface\nNative OpenTelemetry support\nBlazing fast query engine\nUp to 90% compression with columnar design\nDocumentation\n|\nHelp\n|\nPrivacy\n|\n© 2026 Parseable Inc.\nLog in to your account\nUsername\nPassword\nForgot password?\nLogin\nOR WITH OAUTH\nLogin with OAuth\nNeed help? Contact support", + "boxes": [ + { + "tag": "FORM", + "name": null, + "text": "Username\nPassword\nForgot password?\nLogin", + "ariaLabel": null, + "box": { + "x": 784, + "y": 313.25, + "width": 448, + "height": 244, + "top": 313.25, + "right": 1232, + "bottom": 557.25, + "left": 784 + } + }, + { + "tag": "INPUT", + "name": "username", + "text": "", + "ariaLabel": null, + "box": { + "x": 784, + "y": 337.25, + "width": 448, + "height": 40, + "top": 337.25, + "right": 1232, + "bottom": 377.25, + "left": 784 + } + }, + { + "tag": "INPUT", + "name": "password", + "text": "", + "ariaLabel": null, + "box": { + "x": 784, + "y": 417.25, + "width": 448, + "height": 40, + "top": 417.25, + "right": 1232, + "bottom": 457.25, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "", + "ariaLabel": null, + "box": { + "x": 1200, + "y": 427.25, + "width": 20, + "height": 20, + "top": 427.25, + "right": 1220, + "bottom": 447.25, + "left": 1200 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Forgot password?", + "ariaLabel": null, + "box": { + "x": 784, + "y": 477.25, + "width": 119.8125, + "height": 20, + "top": 477.25, + "right": 903.8125, + "bottom": 497.25, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Login", + "ariaLabel": null, + "box": { + "x": 784, + "y": 517.25, + "width": 448, + "height": 40, + "top": 517.25, + "right": 1232, + "bottom": 557.25, + "left": 784 + } + }, + { + "tag": "BUTTON", + "name": null, + "text": "Login with OAuth", + "ariaLabel": null, + "box": { + "x": 784, + "y": 646.75, + "width": 448, + "height": 40, + "top": 646.75, + "right": 1232, + "bottom": 686.75, + "left": 784 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/prism-login.png b/frontend/docs/parity-screenshots/prism-login.png new file mode 100644 index 000000000..a7746fe38 Binary files /dev/null and b/frontend/docs/parity-screenshots/prism-login.png differ diff --git a/frontend/docs/parity-screenshots/prism-logs.json b/frontend/docs/parity-screenshots/prism-logs.json new file mode 100644 index 000000000..936659fbb --- /dev/null +++ b/frontend/docs/parity-screenshots/prism-logs.json @@ -0,0 +1,881 @@ +{ + "url": "http://127.0.0.1:8260/logs/explore/frontend_smoke?start=10m&end=now&type=fixed&tz=UTC", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Community\nLogs\nfrontend_smoke\nConnect MCP\nFS\nCtrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlerts\nErrors\nDATA\nDatasets\nSettings\nBook a call\nSupport\nSidebar controls\nCore\nLog category\nLog formats\nUser agents\nSource IPs\nAll Fields\n(7)\nlevel\nmessage\np_src_ip\np_timestamp\np_user_agent\nrequest_id\nservice\nLast 10 minutes\nUTC\nOff\nSave\nSummarize\nGroup by\nLogs over time\nCount\nForecast\n12 records\n1 / 1\n50 / page\nAdd columns\nIngestion Time (UTC)\nData\n2026-10-07T14:05:05.232\nlevel\n=\nINFO\nmessage\n=\nLive smoke request complete\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-4\nservice\n=\nfrontend-smoke\n2026-10-07T14:05:05.232\nlevel\n=\nWARN\nmessage\n=\nLive smoke retry scheduled\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-3\nservice\n=\nfrontend-smoke\n2026-10-07T14:05:05.232\nlevel\n=\nERROR\nmessage\n=\nLive smoke payment timeout\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-2\nservice\n=\nfrontend-smoke\n2026-10-07T14:05:05.232\nlevel\n=\nINFO\nmessage\n=\nLive smoke request accepted\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-1\nservice\n=\nfrontend-smoke\n2026-10-07T14:00:44.985\nlevel\n=\nINFO\nmessage\n=\nLive smoke request complete\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nPython-urllib/3.14\nrequest_id\n=\nsmoke-4\nservice\n=\nfrontend-smoke\n2026-10-07T14:00:44.985\nlevel\n=\nWARN\nmessage\n=\nLive smoke retry scheduled\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nPython-urllib/3.14\nrequest_id\n=\nsmoke-3\nservice\n=\nfrontend-smoke\n2026-10-07T14:00:44.985\nlevel\n=\nERROR\nmessage\n=\nLive smoke payment timeout\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nPython-urllib/3.14\nrequest_id\n=\nsmoke-2\nservice\n=\nfrontend-smoke\n2026-10-07T14:00:44.985\nlevel\n=\nINFO\nmessage\n=\nLive smoke request accepted\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nPython-urllib/3.14\nrequest_id\n=\nsmoke-1\nservice\n=\nfrontend-smoke\n2026-10-07T14:02:12.607\nlevel\n=\nINFO\nmessage\n=\nLive smoke request complete\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-4\nservice\n=\nfrontend-smoke\n2026-10-07T14:02:12.607\nlevel\n=\nWARN\nmessage\n=\nLive smoke retry scheduled\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-3\nservice\n=\nfrontend-smoke\n2026-10-07T14:02:12.607\nlevel\n=\nERROR\nmessage\n=\nLive smoke payment timeout\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-2\nservice\n=\nfrontend-smoke\n2026-10-07T14:02:12.607\nlevel\n=\nINFO\nmessage\n=\nLive smoke request accepted\np_src_ip\n=\n127.0.0.1\np_user_agent\n=\nMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.8010.12 Safari/537.36\nrequest_id\n=\nsmoke-1\nservice\n=\nfrontend-smoke", + "issues": [], + "boxes": [ + { + "tag": "HEADER", + "hook": "", + "text": "Community\nLogs\nfrontend_smoke\nConnect MCP\nFS", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "content", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Ctrl K", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 40, + "top": 48, + "right": 191, + "bottom": 88, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "INPUT", + "hook": "sidebar-search", + "text": "", + "box": { + "x": 41, + "y": 60, + "width": 98.546875, + "height": 28, + "top": 60, + "right": 139.546875, + "bottom": 88, + "left": 41 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Home\nIngest telemetry", + "box": { + "x": 0, + "y": 88, + "width": 191, + "height": 70, + "top": 88, + "right": 191, + "bottom": 158, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Home\nIngest telemetry", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 58, + "top": 100, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nKeystone\nDashboards\nSQL Editor", + "box": { + "x": 0, + "y": 158, + "width": 191, + "height": 126, + "top": 158, + "right": 191, + "bottom": 284, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Keystone\nDashboards\nSQL Editor", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 88, + "top": 196, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 0, + "y": 284, + "width": 191, + "height": 186, + "top": 284, + "right": 191, + "bottom": 470, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Logs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 148, + "top": 322, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "MONITOR\nAlerts\nErrors", + "box": { + "x": 0, + "y": 470, + "width": 191, + "height": 96, + "top": 470, + "right": 191, + "bottom": 566, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Alerts\nErrors", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 58, + "top": 508, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 0, + "y": 566, + "width": 191, + "height": 66, + "top": 566, + "right": 191, + "bottom": 632, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Settings\nBook a call\nSupport\nSidebar controls", + "box": { + "x": 0, + "y": 874, + "width": 191, + "height": 126, + "top": 874, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Settings\nBook a call\nSupport", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 88, + "top": 874, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Core\nLog category\nLog formats\nUser agents\nSource IPs\nAll Fields\n(7)\nlevel\nmessage\np_src_ip\np_timestamp\np_user_agent\nrequ", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + }, + { + "tag": "BUTTON", + "hook": "lmt-sidebar-toggle", + "text": "", + "box": { + "x": 448, + "y": 57.5, + "width": 28, + "height": 28, + "top": 57.5, + "right": 476, + "bottom": 85.5, + "left": 448 + } + }, + { + "tag": "INPUT", + "hook": "add-filter-button", + "text": "", + "box": { + "x": 482, + "y": 112, + "width": 786.4375, + "height": 20, + "top": 112, + "right": 1268.4375, + "bottom": 132, + "left": 482 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/prism-logs.png b/frontend/docs/parity-screenshots/prism-logs.png new file mode 100644 index 000000000..27dbb13d6 Binary files /dev/null and b/frontend/docs/parity-screenshots/prism-logs.png differ diff --git a/frontend/docs/parity-screenshots/prism-sql.json b/frontend/docs/parity-screenshots/prism-sql.json new file mode 100644 index 000000000..3bf8dd1a4 --- /dev/null +++ b/frontend/docs/parity-screenshots/prism-sql.json @@ -0,0 +1,851 @@ +{ + "url": "http://127.0.0.1:8260/sql-editor?start=10m&end=now&type=fixed&tz=UTC", + "viewport": { + "width": 1440, + "height": 1000 + }, + "text": "Community\nSQL editor\nConnect MCP\nFS\nCtrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlerts\nErrors\nDATA\nDatasets\nSettings\nBook a call\nSupport\nSidebar controls\nQuery 1\nExplorer\nLast 10 minutes\nUTC\nOff\nView library\nAI assistant\nDatasets\nNo datasets selected.\nColumns\nSelect a dataset to view its columns.\n1\n\n\nRun\nCtrl\n↵\nSave\nRun a query to view results\nGenerate with AI", + "issues": [], + "boxes": [ + { + "tag": "HEADER", + "hook": "", + "text": "Community\nSQL editor\nConnect MCP\nFS", + "box": { + "x": 0, + "y": 0, + "width": 1440, + "height": 48, + "top": 0, + "right": 1440, + "bottom": 48, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 192, + "height": 952, + "top": 48, + "right": 192, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "sidebar", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "content", + "text": "Ctrl K\nHome\nIngest telemetry\nANALYZE\nKeystone\nDashboards\nSQL Editor\nOBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents\nMONITOR\nAlert", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 952, + "top": 48, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Ctrl K", + "box": { + "x": 0, + "y": 48, + "width": 191, + "height": 40, + "top": 48, + "right": 191, + "bottom": 88, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ctrl K", + "box": { + "x": 8, + "y": 60, + "width": 175, + "height": 28, + "top": 60, + "right": 183, + "bottom": 88, + "left": 8 + } + }, + { + "tag": "INPUT", + "hook": "sidebar-search", + "text": "", + "box": { + "x": 41, + "y": 60, + "width": 98.546875, + "height": 28, + "top": 60, + "right": 139.546875, + "bottom": 88, + "left": 41 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Home\nIngest telemetry", + "box": { + "x": 0, + "y": 88, + "width": 191, + "height": 70, + "top": 88, + "right": 191, + "bottom": 158, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Home\nIngest telemetry", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 58, + "top": 100, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Home", + "box": { + "x": 8, + "y": 100, + "width": 175, + "height": 28, + "top": 100, + "right": 183, + "bottom": 128, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Ingest telemetry", + "box": { + "x": 8, + "y": 130, + "width": 175, + "height": 28, + "top": 130, + "right": 183, + "bottom": 158, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "ANALYZE\nKeystone\nDashboards\nSQL Editor", + "box": { + "x": 0, + "y": 158, + "width": 191, + "height": 126, + "top": 158, + "right": 191, + "bottom": 284, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Keystone\nDashboards\nSQL Editor", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 88, + "top": 196, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Keystone", + "box": { + "x": 8, + "y": 196, + "width": 175, + "height": 28, + "top": 196, + "right": 183, + "bottom": 224, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Dashboards", + "box": { + "x": 8, + "y": 226, + "width": 175, + "height": 28, + "top": 226, + "right": 183, + "bottom": 254, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "SQL Editor", + "box": { + "x": 8, + "y": 256, + "width": 175, + "height": 28, + "top": 256, + "right": 183, + "bottom": 284, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "OBSERVE\nLogs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 0, + "y": 284, + "width": 191, + "height": 186, + "top": 284, + "right": 191, + "bottom": 470, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Logs\nMetrics\nTraces\nAPM\nAgents", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 148, + "top": 322, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Logs", + "box": { + "x": 8, + "y": 322, + "width": 175, + "height": 28, + "top": 322, + "right": 183, + "bottom": 350, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Metrics", + "box": { + "x": 8, + "y": 352, + "width": 175, + "height": 28, + "top": 352, + "right": 183, + "bottom": 380, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Traces", + "box": { + "x": 8, + "y": 382, + "width": 175, + "height": 28, + "top": 382, + "right": 183, + "bottom": 410, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "APM", + "box": { + "x": 8, + "y": 412, + "width": 175, + "height": 28, + "top": 412, + "right": 183, + "bottom": 440, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Agents", + "box": { + "x": 8, + "y": 442, + "width": 175, + "height": 28, + "top": 442, + "right": 183, + "bottom": 470, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "MONITOR\nAlerts\nErrors", + "box": { + "x": 0, + "y": 470, + "width": 191, + "height": 96, + "top": 470, + "right": 191, + "bottom": 566, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Alerts\nErrors", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 58, + "top": 508, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Alerts", + "box": { + "x": 8, + "y": 508, + "width": 175, + "height": 28, + "top": 508, + "right": 183, + "bottom": 536, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Errors", + "box": { + "x": 8, + "y": 538, + "width": 175, + "height": 28, + "top": 538, + "right": 183, + "bottom": 566, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "DATA\nDatasets", + "box": { + "x": 0, + "y": 566, + "width": 191, + "height": 66, + "top": 566, + "right": 191, + "bottom": 632, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Datasets", + "box": { + "x": 8, + "y": 604, + "width": 175, + "height": 28, + "top": 604, + "right": 183, + "bottom": 632, + "left": 8 + } + }, + { + "tag": "DIV", + "hook": "group", + "text": "Settings\nBook a call\nSupport\nSidebar controls", + "box": { + "x": 0, + "y": 874, + "width": 191, + "height": 126, + "top": 874, + "right": 191, + "bottom": 1000, + "left": 0 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Settings\nBook a call\nSupport", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 88, + "top": 874, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Settings", + "box": { + "x": 8, + "y": 874, + "width": 175, + "height": 28, + "top": 874, + "right": 183, + "bottom": 902, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "A", + "hook": "menu-button", + "text": "Book a call", + "box": { + "x": 8, + "y": 904, + "width": 175, + "height": 28, + "top": 904, + "right": 183, + "bottom": 932, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Support", + "box": { + "x": 8, + "y": 934, + "width": 175, + "height": 28, + "top": 934, + "right": 183, + "bottom": 962, + "left": 8 + } + }, + { + "tag": "UL", + "hook": "menu", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "LI", + "hook": "menu-item", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "BUTTON", + "hook": "menu-button", + "text": "Sidebar controls", + "box": { + "x": 8, + "y": 964, + "width": 175, + "height": 28, + "top": 964, + "right": 183, + "bottom": 992, + "left": 8 + } + }, + { + "tag": "MAIN", + "hook": "main-section", + "text": "Query 1\nExplorer\nLast 10 minutes\nUTC\nOff\nView library\nAI assistant\nDatasets\nNo datasets selected.\nColumns\nSelect a datas", + "box": { + "x": 192, + "y": 48, + "width": 1248, + "height": 952, + "top": 48, + "right": 1440, + "bottom": 1000, + "left": 192 + } + } + ] +} diff --git a/frontend/docs/parity-screenshots/prism-sql.png b/frontend/docs/parity-screenshots/prism-sql.png new file mode 100644 index 000000000..35f1ca847 Binary files /dev/null and b/frontend/docs/parity-screenshots/prism-sql.png differ diff --git a/frontend/docs/parity-screenshots/production-demo-gate.json b/frontend/docs/parity-screenshots/production-demo-gate.json new file mode 100644 index 000000000..89abdfde0 --- /dev/null +++ b/frontend/docs/parity-screenshots/production-demo-gate.json @@ -0,0 +1,24 @@ +{ + "date": "2026-10-07", + "results": [ + { + "build": "VITE_ENABLE_DEMO=false", + "checks": [ + "persisted demo selection ignored", + "missing session returns to login", + "demo button absent", + "component gallery accessible" + ], + "passed": true + }, + { + "build": "VITE_ENABLE_DEMO=true", + "checks": [ + "explicit demo entry available", + "selected demo persists in tab", + "reload remains demo" + ], + "passed": true + } + ] +} diff --git a/frontend/docs/parity-screenshots/sql-comparison.png b/frontend/docs/parity-screenshots/sql-comparison.png new file mode 100644 index 000000000..b443cb3ab Binary files /dev/null and b/frontend/docs/parity-screenshots/sql-comparison.png differ diff --git a/frontend/docs/prism-parity.md b/frontend/docs/prism-parity.md new file mode 100644 index 000000000..774c88bbf --- /dev/null +++ b/frontend/docs/prism-parity.md @@ -0,0 +1,75 @@ +# Prism parity evidence and next actions + +This comparison uses the pinned Prism v3.2.4 archive (SHA-1 `5e3ec81fbb35b2b8aa6486a2bd38257d46b0264b`) and the editable frontend in this worktree. It describes an independent implementation; original TypeScript source and source maps were not recovered. Read-only reports, DOM captures and screenshots informed the work. No minified implementation was copied. + +## Evidence ledger + +| Evidence | What it establishes | Limit | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| [Atlas overview](~/.local/share/prism-ui-research/wiki-export/pages/research/prism-ui.md) and [manifest](~/.local/share/prism-ui-research/v3.2.4/artifact-manifest.json) | Archive identity and 205 reachable JS / 4 CSS assets | Reachable does not mean enabled or rendered | +| [Runtime report](~/.local/share/prism-ui-research/v3.2.4/runtime-report.md), [login DOM](~/.local/share/prism-ui-research/v3.2.4/runtime-login.json), [login image](~/.local/share/prism-ui-research/v3.2.4/runtime-login.png) | Actual unauthenticated login at 1440 × 1000, observed 2026-10-06 | No backend, no submitted login | +| [Reset DOM](~/.local/share/prism-ui-research/v3.2.4/runtime-forgot-password.json), [reset image](~/.local/share/prism-ui-research/v3.2.4/runtime-forgot-password.png), [password toggle](~/.local/share/prism-ui-research/v3.2.4/runtime-password-toggle.json) | Open dialog and local password visibility behavior | Does not establish authentication or recovery API behavior | +| [Component report](~/.local/share/prism-ui-research/wiki-export/pages/research/prism-ui/components.md) and [selector report](~/.local/share/prism-ui-research/wiki-export/pages/research/prism-ui/selectors.md) | Static route, label, selector, dependency and composition evidence | Authenticated layouts and conditional availability initially unobserved | +| [Selector inventory attachment](~/.local/share/prism-ui-research/wiki-export/attachments/e64b56ee-6a0d-4fec-a13a-6185547f38db/selector-inventory.json), [offset verification](~/.local/share/prism-ui-research/wiki-export/attachments/25e301dc-e1a3-4414-a874-a311078fd6f2/evidence-check.json) | 12,480 property/style records; 50 component markers verified | Records include library internals and non-DOM object properties; not 12,480 working selectors | +| [OIDC authorization record](~/.local/share/prism-ui-research/wiki-export/pages/oidc/authorization-plan.md) and [Pocket ID record](~/.local/share/prism-ui-research/wiki-export/pages/oidc/pocket-id-setup.md) | Prior backend design and deployment acceptance context | Results belong to another checkout/deployment; they are not validation of this frontend | + +Absolute evidence links above refer to the supplied local research cache. They are useful on this machine but are not portable repository assets. The [versioned upstream archive](https://parseableprismbuild.blob.core.windows.net/parseable-prism-build/v3.2.4/build.zip) is the reproducible reference. + +## Prioritized gaps + +This table records the baseline gaps found before the current iteration. The final implemented-scope section below reconciles these baseline actions with the completed changes and test results; a planned action is not a passed test. P0 means session correctness and access; P1 means the largest visible or interaction differences in existing routes; P2 records deferred work outside this iteration; it does not authorize adding new feature areas. + +| Priority / area | Baseline gap and actionable target | Wording, layout and selectors | Evidence / acceptance | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| P0 Login | Replace the shell-embedded welcome placeholder and connection-only form with a standalone login screen; retain explicit demo entry | Centered two-column card, approximately 1152 × 645 at 1440 × 1000, 28px corners. Left brand and lavender patterned panel; right white credentials panel. “Log in to your account”, “Username”, “Password”, “Forgot password?”, “Login”, “OR WITH OAUTH”, “Login with OAuth”. `input[name=username]`, `input[name=password]` | Runtime login JSON/image above. Verify keyboard submit, password clearing, unsuccessful login, session reload and protected deep-link return separately | +| P0 OAuth | Provide browser navigation into the native backend OAuth flow; determine backend availability without collecting provider secrets | OAuth is a separate action, never Basic auth over a fetch redirect to the provider. Preserve only validated local `next` destinations | OIDC records above inform intent; exact endpoint and redirect behavior require this checkout's Rust handlers and a disposable-provider test | +| P0 Session/recovery | Probe session before protected content; distinguish 401 from 403 and transport failures. Keep sign-out reachable on error/no-access pages | Authentication loss should return to login with local destination retained; permission denial should remain a visible denial. Account menu/recovery action named “Sign out” | Prior deployed error-recovery behavior is not this checkout’s backend contract: `src/handlers/http/oidc.rs::logout` redirects OAuth users to a configured provider logout URL and otherwise to the requested URL. Verify local invalidation before treating a provider redirect as success | +| P1 Password controls | Add local visibility toggle and reset information dialog | Dialog named “Reset password”; “Please contact admin to reset your password”. `[data-dialog-close]`, `role=dialog`. Name the visibility button “Show password” / “Hide password” | Runtime reset/toggle captures. Original toggle lacks an accessible name; reproduce behavior while improving semantics | +| P1 Shell | Match compact persistent sidebar, selected navigation, breadcrumb and account controls for supported routes | `#main-section`, `[data-testid=sidebar]`, `sidebar-home`, `sidebar-logs`, `sidebar-sql-editor`, `sidebar-dashboards`, `sidebar-datasets`; `[data-sidebar=header/content/footer/trigger/menu-button]`, `data-state`, `data-active` | Selector report, App chunk offsets 7336–17459 and 41677–42636. Authenticated dimensions were subsequently measured below. Do not add dead enterprise navigation for appearance | +| P1 Logs toolbar | Reduce extra page-card framing and compose field panel, dataset context, time, refresh, filter and query controls | `[data-testid=lmt-sidebar-toggle]`, `[data-testid=add-filter-button]`, `[data-filter-pill-id]`, `[data-testid=filter-pill-value]`; clear filters and SQL handoff | Component report “Logs explorer”; Explore @7521 and Filter @12778/14465/14686. Verify filter round-trip, schema changes and preserved time range | +| P1 Log details/table | Align column and record actions and make all actions keyboard reachable | “Sort {column}”, “Column actions for {column}”, “Add columns”, “Include in filter”, “Exclude from filter”, “Copy key value pair”, “Export CSV”, “Export JSON”. `[data-index]` is only row position | LogsExplore @5583/34153 and FieldsListing report. Existing paging is acceptable until virtualization is implemented; avoid claiming virtualized parity | +| P1 Time range | Expand presets into an explicit absolute-time editing flow, or document the preset-only limit | From / To / Now / timezone, Cancel / Apply. Keep draft values separate until Apply; cancel must preserve the applied range | TimeRange composition in component report. Runtime date/timezone interaction remains to be observed | +| P1 SQL | Use a compact editor/results composition with consistent labels and action placement | “Run query”, “Results”, “Chart”, `[aria-label="Export SQL results"]`, `[aria-label="Toggle row wrapping"]`; CodeMirror stays behind an editor wrapper | SQL report; SQL @11735/12328. Execution shortcut, export and errors can be checked independently of missing chart mode | +| P1 Datasets | Use a searchable inventory table with inspect/explore actions; avoid marketing-card styling | “Search by name”; separate dataset name/type and actions; schema belongs in a details sheet | Component report “Other feature families”. Rich tags/retention/cache/index controls require verified backend contracts | +| P1 Dashboards | Preserve the distinction between browser-local tiles and server dashboards; improve list/tile hierarchy without implying sync | `[data-tile-id]` from stable model identity; “Add tile”, “Open dashboard actions”; creation/deletion controls composed independently | Dashboard report @246330 and endpoints `api/v1/dashboards`. Local persistence is not server/dashboard parity | +| P2 Explorer depth | Server histogram counts, saved views, grouped filters, related-log context, export/copy-link menus and wide-schema virtualization | “Log context”, “Referenced line”, “Search context data”, `[aria-label="Close row context"]`; separate result-row histogram from total counts | LogsExplore @25583/30777/58962. `/api/v1/counts` and `/api/v1/query/context` are static endpoint evidence until verified | +| P2 SQL depth | Query tabs, multi-dataset picker, visualizations, saved-query library and resize panels | “New tab”, “Select up to 10 datasets”, “Add to Dashboard”, `#legend-container-sql-editor`, title “Drag to resize sidebar/right panel” | SQL @33593/36909/52928. Static SQL default limit is 500; do not silently pretend existing 100-row explorer is equivalent | +| P2 Dataset management | Add permissions-aware tags, retention, partition/cache/index editing and destructive confirmation after API validation | “Save dataset tags”, “Cancel editing tags”, “Delete dataset”, “Back to index list”; `[data-dialog-confirm]`, `#Partition` for creation partition | Component and selector reports. Read inventory/schema alone is not management parity | +| P2 Dashboard depth | Server persistence, detail routes, sections, variables, tile queries/style controls, resize/reorder | `[data-section-drag-handle=true]`, `[data-section-drop-area=true]`, `#var-form`, `.react-resizable-handle`; “Rename/Delete/Reorder {section}” | Dashboard @218548/229658/245810. These markers should be added only with their behavior | + +## Visual and selector rules + +Preserve the light primary `#3a3a8c`, neutral semantic roles, small control heights and restrained borders. Prism's cool-gray numeric direction differs from conventional Tailwind scales; map meaning rather than copying numeric token assumptions. Inter and JetBrains Mono are reference fonts; local fallbacks remain an explicit difference unless assets are supplied. Generated utility strings, hashed asset names and `radix-:…` IDs are unsuitable public component contracts. + +Use accessible role/name for interactions, stable feature hooks for owner boundaries, and explicit DOM IDs only where confirmed. Scope repeated filter/legend hooks to their feature. Keep primitives free of fetching/authentication, domain state in feature owners, and third-party editor internals behind adapters. Billing, Stripe, checkout, subscriptions and Clerk are outside this implementation. + +## Validation status + +The supplied 2026-10-06 artifacts establish original Prism login rendering and local controls only. Baseline implementation screenshots and earlier test counts in the cache describe the preceding iteration. Authenticated original Prism captures, final implementation captures and current live-backend checks are now recorded below and in [validation-review.md](validation-review.md), with actual commands/results. Pixel equality and interoperability with a real identity provider remain unverified. + +## Fresh login comparison + +[Side-by-side login capture](parity-screenshots/login-comparison.png) compares original Prism at `127.0.0.1:8260` with the final editable frontend at `127.0.0.1:8270`, both at 1440 × 1000. The combined PNG scales each screenshot to 960px wide and labels them; it is composed from actual browser screenshots. [Original DOM measurements](parity-screenshots/prism-login.json) and [editable DOM measurements](parity-screenshots/editable-login.json) accompany the source PNGs. These artifacts were refreshed after the measurement-driven implementation refinements. + +The initial comparison confirmed a 1152px reference card with 576px columns and approximately 448px credential fields. It identified concrete editable differences in card vertical position, logo placement, heading scale, benefit density and forgot-password alignment; these were sent to the implementation owner. The capture did not submit credentials or establish backend compatibility. + +## Authenticated reference observations (2026-10-07) + +A disposable native account signed into original Prism at port 8260, proxied to the worktree-built backend on port 8250 with synthetic events in `frontend_smoke` (four initially; later captures include repeated test ingestion). The same account signed into the editable frontend at port 8270. All four original routes rendered without a captured JavaScript page error. These observations establish actual authenticated layouts and replace the initial static-only uncertainty for the captured states. They do not verify every hidden control or feature. + +| Screen / evidence | Measured original layout and wording | Remaining comparison limits | +| ------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Logs comparison](parity-screenshots/logs-comparison.png), [original DOM](parity-screenshots/prism-logs.json) | Full-width 48px topbar; 192px navigation below it; 240px field panel. Toolbar y48–95, filters y108, “Logs over time” chart, records/pagination strip y306–342. Default results have “Ingestion Time (UTC)” and flattened “Data” columns, about 36px rows | Synthetic fixture records; final editable screenshot includes measurement-driven refinements; preset ranges differ | +| [SQL comparison](parity-screenshots/sql-comparison.png), [original DOM](parity-screenshots/prism-sql.json) | Query tab strip y48–87, toolbar y87–140, 272px Explorer sidebar, editor from x474/y150 to y358, run/footer to y403; empty “Run a query to view results” | Initial empty editor state; successful results and chart mode not captured in this reference pass | +| [Datasets comparison](parity-screenshots/datasets-comparison.png), [original DOM](parity-screenshots/prism-datasets.json) | 16px content inset, heading “Datasets”, description “Queryable containers where your logs, metrics, or traces live.”; All Datasets/Logs/Metrics/Traces tabs and Search by name; compact inventory rows | Original metadata metrics and management controls exceed this iteration's read-only inventory contract | +| [Dashboards comparison](parity-screenshots/dashboards-comparison.png), [original DOM](parity-screenshots/prism-dashboards.json) | “Dashboards”, “Visualize and explore your telemetry data”; “New dashboard”, template strip and My Dashboards/All Dashboards/Favorites/Template Gallery tabs; empty “No Dashboards Yet” | Original is server-backed; this iteration intentionally retains browser-local dashboards. Template/AI creation and server persistence are outside scope | + +Original authenticated shell geometry was relayed to the implementation owners immediately. The side-by-side artifacts are evidence of observed differences, not a claim that the implementation has reached pixel parity. Screenshots include only disposable fixture data and identity; no cookies or tokens were saved in the JSON. + +## Final implemented scope and remaining visual differences + +The final captures show the shared 48px header, 192px navigation, 240px Logs field panel, compact summary rows, SQL explorer/editor split, table inventory and split login. Password visibility, reset guidance, SSO navigation, identity, session recovery and build-gated demo behavior are implemented. Logs add column search/selection, sorting, wrapping, edit/clear filter actions, UTC absolute ranges, JSON/CSV export and structured/JSON event details. SQL shares those result controls and starts empty on a live server until a dataset or query is supplied. + +Intentional differences remain: only implemented navigation is shown; the login action says “Sign in with SSO”; help points to the administrator; local fonts/icons and some spacing differ; sample and live badges are explicit. Logs default to a one-hour range rather than original ten minutes, use bounded returned-row histogram counts, and place pagination below results. The time editor currently supports UTC only, and SQL has one query tab without chart/view-library/AI panels. Dataset metadata metrics are not invented. Dashboards remain browser-local and cannot claim original server-backed dashboard parity. + +[Mobile login](parity-screenshots/editable-login-mobile.png) and [mobile Logs](parity-screenshots/editable-logs-mobile.png) record the final 390 × 844 layout. These screenshots are visual evidence, not an accessibility conformance result; automated checks are recorded separately. diff --git a/frontend/docs/validation-review.md b/frontend/docs/validation-review.md new file mode 100644 index 000000000..7ee7decfc --- /dev/null +++ b/frontend/docs/validation-review.md @@ -0,0 +1,133 @@ +# Frontend validation and independent review + +This record distinguishes the current worktree's completed checks from the supplied earlier research. Original Prism source was not recovered; visual comparisons use actual rendered v3.2.4 assets and an independently implemented frontend. No deployed service or the reference checkout was modified. + +## Completed current validation (2026-10-07) + +| Check | Actual result | Scope | +| -------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Worktree Rust build | `cargo build --locked --bin parseable` passed; final crate rebuild 46s after dependencies compiled | Real local-store executable, using a stable editable asset snapshot. `--help` and `local-store --help` also passed | +| Live backend | Port 8250 liveness returned 200; synthetic logs ingested into `frontend_smoke` | Disposable storage under `/tmp/astra-item2-data`; no deployed data | +| Independent final live Playwright pass | **7 passed, 1 expected skip in 20.1s** | Final current code: native/session/logs/SQL/schema plus mock OIDC and administrative API denial; missing-provider test ran separately | +| Authenticated original Prism capture | Four original routes rendered with no captured JavaScript page errors | Logs, initial SQL, datasets and empty server-dashboard listing, native fixture account; 1440 × 1000 | +| Final implementation capture | Login and four supported routes refreshed after layout refinements; mobile login/Logs at 390 × 844 | Real native session, no API mocking; original/editable screenshots composed side by side | + +The final OIDC-configured live command was: + +```sh +cd frontend +npm_config_cache=/tmp/astra-item2-npm \ +TMPDIR=/tmp/astra-item2-tmp \ +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ +PARSEABLE_LIVE_URL=http://127.0.0.1:8270 \ +PARSEABLE_LIVE_OIDC=true \ +npm run test:live +``` + +Assertions verified backend invalidation by reusing the old native session cookie and receiving HTTP 401. A missing browser session and a server-invalidated session both returned to login, retained the intended dataset route and recovered after sign-in. The log test used ingested values, applied an exact ERROR filter, opened details using Enter and checked Escape focus restoration. SQL ran on DataFusion and a missing-table query produced a real error with stale results removed. The dataset sheet showed Arrow-schema fields and navigated back to logs. + +The final OIDC case also confirmed that the restricted user received HTTP 403 from the administrative `/api/v1/roles` endpoint. This is live API permission evidence; the permission-denied UI itself is covered by the mocked browser regression. The OIDC case navigated through the visible local provider on port 8251, waited for the real `/api/v1/o/code` browser callback, verified “Smoke SSO User” and accessible data, signed out, and re-entered SSO successfully with stale-cookie handling. This validates the configured local mock protocol round trip, not Pocket ID or another real provider. The mock uses discovery, JWKS and RS256 ID tokens; it is a loopback fixture, not a production identity service. + +The provider-free backend case separately passed **1/1 in 2.6s** after the same disposable server restarted without `P_OIDC_*` configuration: + +```sh +npm_config_cache=/tmp/astra-item2-npm TMPDIR=/tmp/astra-item2-tmp \ +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ +PARSEABLE_LIVE_URL=http://127.0.0.1:8270 PARSEABLE_LIVE_NO_OIDC=true \ +npm run test:live -- --grep 'unconfigured OIDC' +``` + +The full default check passed with **36 unit tests (5 files), 33 app browser tests, and 14 Storybook browser tests**. Formatting, TypeScript, production Vite build and Storybook build passed in the same command. App browser tests completed in 18.0s and Storybook tests in 5.7s, using system Chromium and the configured ten workers. Browser execution was serialized across agents. + +```sh +npm_config_cache=/tmp/astra-item2-npm TMPDIR=/tmp/astra-item2-tmp \ +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium npm run check +``` + +An initial check stopped with two test failures: an inexact Password locator also matched the newly named visibility button, and Chromium normalized zero-second `datetime-local` values so a fixture fill was rejected. Exact label matching and canonical minute-only fixture strings fixed those test assumptions. The complete command passed on rerun. Non-fatal Storybook chunk-size/deprecation warnings remain; there were no build or test failures in the final run. + +## Reproduce the disposable live environment + +The following values are public local fixture values, not deployment credentials. Reserve the listed loopback ports before starting processes. Stop the fixture processes afterward. + +Build from the repository root. The successful session isolated Cargo/cache/temp output and used a snapshot of built assets so a concurrent frontend build could not delete Rust include files: + +```sh +export PATH="$HOME/.cargo/bin:$PATH" +mkdir -p /tmp/astra-item2-assets /tmp/astra-item2-tmp +cp -a frontend/dist/. /tmp/astra-item2-assets/ +CARGO_HOME=/tmp/astra-item2-cargo \ +CARGO_TARGET_DIR=/tmp/astra-item2-target \ +CARGO_PROFILE_DEV_DEBUG=0 CARGO_BUILD_JOBS=6 CARGO_INCREMENTAL=0 \ +LOCAL_ASSETS_PATH=/tmp/astra-item2-assets TMPDIR=/tmp/astra-item2-tmp \ +cargo build --locked --bin parseable +``` + +This session copied an available Cargo registry cache into the isolated Cargo home; a fresh environment may download dependencies. The initial build attempt hit a missing generated frontend asset while another build replaced `dist`; the stable snapshot resolved that build-input race without changing Rust. + +Start the mock provider from `frontend`: + +```sh +node e2e-live/fixtures/mock-oidc.mjs +``` + +Start the worktree-built backend from the repository root: + +```sh +P_ADDR=127.0.0.1:8250 \ +P_GRPC_PORT=8252 P_FLIGHT_PORT=8253 P_QUERY_GRPC_PORT=8254 \ +P_USERNAME=frontend-smoke P_PASSWORD=local-smoke-password \ +P_FS_DIR=/tmp/astra-item2-data/store \ +P_STAGING_DIR=/tmp/astra-item2-data/staging \ +P_SEND_ANONYMOUS_USAGE_DATA=false P_CHECK_UPDATE=false \ +P_ORIGIN_URI=http://127.0.0.1:8270 \ +P_ALLOW_ORIGINS=http://127.0.0.1:8260,http://127.0.0.1:8270 \ +P_OIDC_CLIENT_ID=frontend-smoke P_OIDC_CLIENT_SECRET=local-fixture-secret \ +P_OIDC_ISSUER=http://127.0.0.1:8251 \ +RUST_LOG=info TMPDIR=/tmp/astra-item2-tmp \ +/tmp/astra-item2-target/debug/parseable local-store +``` + +Start Vite from `frontend`: + +```sh +PARSEABLE_PROXY_TARGET=http://127.0.0.1:8250 npm run dev -- --port 8270 +``` + +The opt-in suite performs its own synthetic ingestion. `PARSEABLE_LIVE_USERNAME`, `PARSEABLE_LIVE_PASSWORD` and `PARSEABLE_LIVE_DATASET` override its defaults. It writes the dataset and, in the OIDC test, a reader role named `frontend-smoke-reader`. A separate invocation with no `PARSEABLE_LIVE_URL` completed successfully with **all 8 tests skipped**, confirming opt-in behavior without the suite ingestion hook. `npm run check` does not run live tests. + +For the original UI reference, `node e2e-live/fixtures/prism-proxy.mjs` serves the read-only cached v3.2.4 distribution on port 8260 with SPA fallback and `/api` proxying to 8250. Native authentication was used for this reference because the mock provider callback is configured for port 8270. See [parity evidence](prism-parity.md) for screenshots, measurements and limits. + +For the missing-provider case, stop the disposable backend and restart it without the three `P_OIDC_*` variables. Use `PARSEABLE_LIVE_NO_OIDC=true` and the targeted missing-provider test. The targeted case passed as recorded above. + +## Component and interaction review + +Primitives own appearance and native/Radix semantics. Feature owners handle query/session state; table/chart presentation does not fetch data. Native controls expose labels; icon buttons have accessible names. Dialogs retain focus containment, Escape dismissal and opener focus restoration. Stable atlas hooks identify feature boundaries, while generated IDs remain accessibility relationships. + +The shell does not expose dead enterprise navigation. Production sample mode is gated at build time. Authentication errors cannot be converted into demo success; permission denial is separate. Identity display prefers the server's user endpoint, with display-only cookie hints when the profile endpoint is unavailable; 401 and cancellation are never treated as a profile fallback. A stream-list probe still checks the session on local-content routes. Backend authorization remains authoritative. Login and logout navigate through the existing backend protocol and do not import the separate OIDC authorization changes from the reference deployment. + +Recovered hooks include `#main-section`, `data-sidebar`, `data-field-node`, `lmt-sidebar-toggle`, filter-pill hooks, `data-dialog-confirm` and stable `data-tile-id`. New CSS and component names are implementation-owned. Pixel parity is not established simply by retaining hooks or passing accessibility tests. + +## Remaining validation limits + +Production demo gating passed in a separate Chromium script against production bundles served at ports 8271 (`VITE_ENABLE_DEMO=false`) and 8272 (`VITE_ENABLE_DEMO=true`). The disabled build ignored a preexisting `parseable-mode=demo`, returned missing-session Logs to login, exposed no demo button and still rendered `/components`. The enabled build offered explicit demo entry and retained the selected demo workspace after reload. [Recorded gate assertions](parity-screenshots/production-demo-gate.json) contain the outcomes. These bundles were built immediately before the final live-SQL initial-text adjustment, which does not affect the gate. + +The gate builds used `VITE_ENABLE_DEMO=false` or `true`, `npm_config_cache=/tmp/astra-item2-npm`, `TMPDIR=/tmp/astra-item2-tmp`, and `npx vite build --outDir /tmp/astra-item2-production-no-demo` or `/tmp/astra-item2-production-demo`. Final source also passed the default production build in `npm run check`. Final screenshots are linked from [prism-parity.md](prism-parity.md). + +Cross-browser behavior, production deployment, real-provider interoperability, provider membership/refresh revocation, complete accessibility conformance and every original Prism interaction are not covered. Dashboards remain browser-local; server-backed dashboards, saved views, metrics, traces, alerts, administration and other excluded feature areas are not implemented. Wide-schema virtualization, arbitrary timezones and exact pixel equality remain parity gaps. + +## Files and test coverage added in this iteration + +| Area | Principal files | Change | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication and shell | `src/app/{App,AppProvider,Sidebar,ConnectionDialog,LoginPage,LoginForm}.tsx`, `src/lib/{auth,client,config,demo,types}.ts`, `src/styles/auth-shell.css` | Standalone login, safe SSO/document navigation, missing-provider recovery, session/identity handling, production demo gate and measured shell layout | +| Logs and shared explorer | `src/features/logs/{LogsPage,FieldSidebar,FilterBar}.tsx`, `src/components/explorer/{ColumnPicker,DataTable,TimeRangePicker,RecordSheet,LogHistogram,QueryState}.tsx`, `src/components/explorer/timestamp.ts`, `src/lib/query.ts` | Compact summary results, columns/sort/wrap/export, edit/clear filters, absolute UTC ranges, event actions, explicit auth states and timezone-less backend UTC parsing | +| Existing route presentation | `src/features/sql/SqlPage.tsx`, `src/features/datasets/DatasetsPage.tsx`, `src/features/dashboards/DashboardsPage.tsx`, `src/styles/global.css` | SQL explorer/empty live editor/results controls, inventory table and local dashboard layout refinements | +| Unit coverage | `src/lib/{auth,client,query}.test.ts`, `src/components/explorer/timestamp.test.ts` | Safe return paths, cookies/identity/auth request contracts, query bounds and backend timestamp handling; existing dashboard storage coverage retained | +| App browser coverage | `e2e/auth.spec.ts`, `e2e/explorer-parity.spec.ts`, `e2e/app.spec.ts`, `playwright.config.ts` | Six auth and five explorer parity cases added; existing flows updated and regression coverage retained | +| Real-server coverage | `e2e-live/server.spec.ts`, `playwright.live.config.ts`, `e2e-live/fixtures/{mock-oidc,prism-proxy}.mjs`, `package.json` | Opt-in live tests, protocol fixture, original-asset proxy and `test:live`; excluded from default check | +| Documentation/evidence | `README.md`, `docs/{api-contracts,component-map,component-design-review,prism-parity,validation-review}.md`, `docs/parity-screenshots/`, `docs/wiki-drafts/prism-frontend-parity.md` | Current contracts, actionable comparison, completed checks, final screenshots and local unpublished wiki draft | + +The README build/embed section and unrelated packaging/Rust/Docker/CI changes belong to concurrent work and were preserved. The final full check preceded only documentation and live-suite assertion adjustments; application source was unchanged afterward. No Rust authorization changes, external publishing, commits or deployed-service changes were made for this frontend iteration. + +A final review run initially assumed the restricted reader would receive HTTP 403 for another stream's schema. The real backend returned HTTP 200 instead. This is metadata visibility, not proof of event-query access, and the frontend does not change existing backend privileges. The acceptance assertion was corrected to use an administrative resource rather than treating globally visible metadata as a denial boundary. After this correction the final live run passed 7 tests with the one expected missing-provider skip, as recorded above. diff --git a/frontend/docs/wiki-drafts/prism-frontend-parity.md b/frontend/docs/wiki-drafts/prism-frontend-parity.md new file mode 100644 index 000000000..2b422dbe7 --- /dev/null +++ b/frontend/docs/wiki-drafts/prism-frontend-parity.md @@ -0,0 +1,23 @@ +# Editable frontend: authentication and Prism parity iteration + +Draft for review only. This file has not been published to the wiki or any external service. + +The editable React/TypeScript frontend now more closely matches pinned Prism v3.2.4 in its supported shell, login, logs, SQL, datasets and dashboard surfaces. This is an independent implementation built around small shared controls and route-owned state; no minified Prism implementation was copied. Backend authorization remains authoritative. Payment, billing, Stripe, subscriptions, checkout and Clerk are excluded. + +## Evidence and scope + +[Prism parity evidence and prioritized gaps](../prism-parity.md) records the supplied archive identity, screenshots, observed login DOM, static feature labels/selectors, and actionable gaps. Prior research rendered only the unauthenticated original login, visibility toggle and password-reset information dialog. Static reachability does not establish a feature's availability, and the prior implementation screenshots are not original Prism screenshots. + +The supplied OIDC wiki pages describe a separate checkout and deployed service. Their successful tests must not be attributed to this worktree. In particular, this worktree's Rust logout handler removes the local session and can redirect OAuth users to the configured identity provider logout URL. Documentation must follow the current handler and validated client behavior rather than assuming the previous deployment's logout overlay. + +## Implementation and validation + +Completed implementation and test results are recorded in [validation-review.md](../validation-review.md). [API contracts](../api-contracts.md) describe the requests made by the client and separate native login, OAuth navigation, session identity and query behavior. Planned or in-progress checks are not acceptance results. + +Open parity work includes features outside the current route scope, richer SQL visualization/query tabs, server-backed dashboards, saved views, server-wide log counts, log context, dataset management and wide-schema virtualization. Browser-local dashboard persistence must remain visibly labeled until server persistence is implemented and tested. + +## Completed acceptance in this worktree + +The worktree-built Rust server ran with disposable local storage and synthetic logs. The final live run passed seven browser tests with one expected provider-free-case skip, including native session invalidation/recovery, logs/filter/detail, DataFusion SQL success/error, Arrow schema and the full local mock OIDC callback round trip. A separate provider-free backend test passed through the real `/oidc-not-configured` redirect and native recovery. A no-URL invocation skipped all eight live tests. Production builds with demo disabled/enabled passed separate browser checks for opt-in behavior, stale selection handling and gallery availability. The complete default check passed: 36 unit, 33 app browser and 14 Storybook browser tests, formatting, TypeScript and both frontend/Storybook builds. + +Authenticated original Prism and final editable screenshots now cover login, Logs, SQL, datasets and dashboards, with composed comparisons under `frontend/docs/parity-screenshots/`. Measurements drove the 48px global header, 192px navigation, 240px Logs field panel, compact summary rows and split login layout. The frontend retains explicit local-dashboard limits, UTC-only absolute-time editing and bounded returned-row histograms. These observations and tests establish the supported workflows, not pixel equality, real-provider compatibility or complete product parity. diff --git a/frontend/e2e-live/fixtures/mock-oidc.mjs b/frontend/e2e-live/fixtures/mock-oidc.mjs new file mode 100644 index 000000000..31dd53401 --- /dev/null +++ b/frontend/e2e-live/fixtures/mock-oidc.mjs @@ -0,0 +1,159 @@ +// A loopback-only OIDC fixture for real Parseable integration tests. Never deploy it. +// Uses real discovery, one-use codes, RS256 ID tokens, JWKS and userinfo. +import http from 'node:http'; +import { generateKeyPairSync, randomUUID, sign } from 'node:crypto'; + +const port = Number(process.env.MOCK_OIDC_PORT || 8251); +const issuer = `http://127.0.0.1:${port}`; +const clientId = process.env.MOCK_OIDC_CLIENT_ID || 'frontend-smoke'; +const clientSecret = process.env.MOCK_OIDC_CLIENT_SECRET || 'local-fixture-secret'; +const callback = process.env.MOCK_OIDC_REDIRECT_URI || 'http://127.0.0.1:8270/api/v1/o/code'; +const frontendOrigin = new URL(callback).origin; +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const kid = randomUUID(); +const jwk = { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' }; +const pending = new Map(); +const codes = new Map(); +const tokens = new Map(); +const user = { + sub: 'frontend-smoke-sso', + name: 'Smoke SSO User', + preferred_username: 'smoke-sso', + email: 'smoke-sso@example.test', + email_verified: true, + groups: ['frontend-smoke-reader'], +}; +const json = (response, status, value) => { + response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify(value)); +}; +const redirect = (response, url) => { + response.writeHead(302, { Location: url, 'Cache-Control': 'no-store' }); + response.end(); +}; +async function form(request) { + let body = ''; + for await (const part of request) { + body += part; + if (body.length > 16_384) throw new Error('Request too large'); + } + return new URLSearchParams(body); +} +function jwt(claims) { + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + const unsigned = `${encode({ alg: 'RS256', kid, typ: 'JWT' })}.${encode(claims)}`; + return `${unsigned}.${sign('RSA-SHA256', Buffer.from(unsigned), privateKey).toString('base64url')}`; +} + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, issuer); + if (url.pathname === '/.well-known/openid-configuration') { + return json(response, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + end_session_endpoint: `${issuer}/logout`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'], + scopes_supported: ['openid', 'profile', 'email'], + claims_supported: Object.keys(user), + grant_types_supported: ['authorization_code'], + }); + } + if (url.pathname === '/jwks') return json(response, 200, { keys: [jwk] }); + if (url.pathname === '/authorize') { + if ( + url.searchParams.get('client_id') !== clientId || + url.searchParams.get('redirect_uri') !== callback || + url.searchParams.get('response_type') !== 'code' + ) { + return json(response, 400, { error: 'invalid_request' }); + } + const ticket = randomUUID(); + pending.set(ticket, { params: url.searchParams, expires: Date.now() + 60_000 }); + response.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + return response.end( + `Local mock identity provider

Local mock identity provider

Sign in as Smoke SSO User for the Parseable live smoke test.

`, + ); + } + if (url.pathname === '/approve' && request.method === 'POST') { + const body = await form(request); + const ticket = body.get('ticket'); + const authorization = pending.get(ticket); + pending.delete(ticket); + if (!authorization || authorization.expires < Date.now()) + return json(response, 400, { error: 'expired_authorization' }); + const code = randomUUID(); + codes.set(code, authorization); + const target = new URL(callback); + target.searchParams.set('code', code); + target.searchParams.set('state', authorization.params.get('state') || ''); + return redirect(response, target.href); + } + if (url.pathname === '/token' && request.method === 'POST') { + const body = await form(request); + const basic = `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`; + if ( + request.headers.authorization !== basic && + !(body.get('client_id') === clientId && body.get('client_secret') === clientSecret) + ) + return json(response, 401, { error: 'invalid_client' }); + const authorization = codes.get(body.get('code')); + codes.delete(body.get('code')); + if ( + body.get('grant_type') !== 'authorization_code' || + body.get('redirect_uri') !== callback || + !authorization || + authorization.expires < Date.now() + ) + return json(response, 400, { error: 'invalid_grant' }); + const accessToken = randomUUID(); + const now = Math.floor(Date.now() / 1000); + tokens.set(accessToken, now + 600); + const nonce = authorization.params.get('nonce'); + return json(response, 200, { + access_token: accessToken, + token_type: 'Bearer', + expires_in: 600, + scope: 'openid profile email', + id_token: jwt({ + iss: issuer, + aud: clientId, + iat: now, + exp: now + 600, + ...user, + ...(nonce ? { nonce } : {}), + }), + }); + } + if (url.pathname === '/userinfo') { + const accessToken = request.headers.authorization?.replace(/^Bearer /i, ''); + if ((tokens.get(accessToken) || 0) < Date.now() / 1000) + return json(response, 401, { error: 'invalid_token' }); + return json(response, 200, user); + } + if (url.pathname === '/logout') { + const target = new URL( + url.searchParams.get('post_logout_redirect_uri') || '/', + frontendOrigin, + ); + if (target.origin !== frontendOrigin) + return json(response, 400, { error: 'invalid_redirect' }); + return redirect(response, target.href); + } + return json(response, 404, { error: 'not_found' }); + } catch (error) { + console.error(error.message); + if (!response.headersSent) json(response, 400, { error: 'invalid_request' }); + else response.end(); + } +}); +server.listen(port, '127.0.0.1', () => + console.log(`Local OIDC fixture: ${issuer}; callback ${callback}`), +); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close()); diff --git a/frontend/e2e-live/fixtures/prism-proxy.mjs b/frontend/e2e-live/fixtures/prism-proxy.mjs new file mode 100644 index 000000000..b431f35f6 --- /dev/null +++ b/frontend/e2e-live/fixtures/prism-proxy.mjs @@ -0,0 +1,63 @@ +// Read-only original Prism assets + same-origin API proxy for visual comparison. +import http from 'node:http'; +import { readFile, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { resolve, extname, sep } from 'node:path'; + +const root = resolve( + process.env.PRISM_ASSETS || `${homedir()}/.local/share/prism-ui-research/v3.2.4/extracted/dist`, +); +const port = Number(process.env.PRISM_PORT || 8260); +const backend = new URL(process.env.PARSEABLE_PROXY_TARGET || 'http://127.0.0.1:8250'); +const mime = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ico': 'image/x-icon', +}; +const server = http.createServer(async (request, response) => { + if (request.url === '/api' || request.url.startsWith('/api/')) { + const upstream = http.request( + { + hostname: backend.hostname, + port: backend.port, + path: request.url, + method: request.method, + headers: request.headers, + }, + (result) => { + response.writeHead(result.statusCode, result.headers); + result.pipe(response); + }, + ); + upstream.on('error', () => { + response.writeHead(502); + response.end('Local Parseable unavailable'); + }); + request.pipe(upstream); + return; + } + try { + const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); + let file = resolve(root, `.${pathname}`); + if (!file.startsWith(`${root}${sep}`) && file !== root) { + response.writeHead(403); + response.end(); + return; + } + if (!(await stat(file).catch(() => undefined))?.isFile()) file = resolve(root, 'index.html'); + const content = await readFile(file); + response.writeHead(200, { 'Content-Type': mime[extname(file)] || 'application/octet-stream' }); + response.end(content); + } catch { + response.writeHead(500); + response.end('Could not read Prism assets'); + } +}); +server.listen(port, '127.0.0.1', () => console.log(`Prism reference: http://127.0.0.1:${port}`)); +for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => server.close()); diff --git a/frontend/e2e-live/server.spec.ts b/frontend/e2e-live/server.spec.ts new file mode 100644 index 000000000..7a396de23 --- /dev/null +++ b/frontend/e2e-live/server.spec.ts @@ -0,0 +1,206 @@ +import { test, expect, type Page } from '@playwright/test'; + +const live = process.env.PARSEABLE_LIVE_URL; +const username = process.env.PARSEABLE_LIVE_USERNAME || 'frontend-smoke'; +const password = process.env.PARSEABLE_LIVE_PASSWORD || 'local-smoke-password'; +const dataset = process.env.PARSEABLE_LIVE_DATASET || 'frontend_smoke'; +const datasetPath = `/logs/explore/${encodeURIComponent(dataset)}`; +// The embedded build is mounted at `/next`; set it empty for a server (or Vite dev) at the root. +const base = (process.env.PARSEABLE_LIVE_BASE ?? '/next').replace(/\/+$/, ''); + +/** Document path for an application route, mirroring `appPath` in src/lib/config.ts. */ +const app = (route: string) => `${base}${route}`; +const appUrl = (route: string) => new URL(app(route), live).href; +const atLogin = (url: URL) => url.pathname === app('/login'); + +test.skip(!live, 'Set PARSEABLE_LIVE_URL to opt in to the real-server suite.'); + +test.beforeAll(async ({ request }) => { + const response = await request.post('/api/v1/ingest', { + headers: { + Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, + 'X-P-Stream': dataset, + }, + data: [ + { + level: 'INFO', + service: 'frontend-smoke', + message: 'Live smoke request accepted', + request_id: 'smoke-1', + }, + { + level: 'ERROR', + service: 'frontend-smoke', + message: 'Live smoke payment timeout', + request_id: 'smoke-2', + }, + { + level: 'WARN', + service: 'frontend-smoke', + message: 'Live smoke retry scheduled', + request_id: 'smoke-3', + }, + { + level: 'INFO', + service: 'frontend-smoke', + message: 'Live smoke request complete', + request_id: 'smoke-4', + }, + ], + }); + expect(response.ok(), await response.text()).toBe(true); +}); + +async function signIn(page: Page, path = datasetPath) { + await page.goto(app(`/login?next=${encodeURIComponent(path)}`)); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page).toHaveURL(appUrl(path)); +} + +test('native login uses a session and shell logout invalidates it', async ({ page }) => { + await signIn(page); + await expect(page.getByRole('table')).toContainText('Live smoke'); + await expect(page.getByText(username, { exact: true }).first()).toBeVisible(); + const savedSession = (await page.context().cookies()).find((cookie) => cookie.name === 'session'); + expect(savedSession).toBeDefined(); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page).toHaveURL(atLogin); + await expect(page.getByRole('table')).toHaveCount(0); + const result = await page.request.get('/api/v1/logstream', { + headers: { Cookie: `session=${savedSession!.value}` }, + }); + expect(result.status()).toBe(401); +}); + +test('missing session asks for login and preserves the intended route', async ({ + page, + context, +}) => { + await signIn(page); + await expect(page.getByRole('table')).toContainText('Live smoke'); + await context.clearCookies(); + await page.reload(); + await expect(page).toHaveURL((url) => atLogin(url) && url.searchParams.has('next')); + expect(new URL(page.url()).searchParams.get('next')).toBe(datasetPath); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page).toHaveURL(appUrl(datasetPath)); + await expect(page.getByRole('table')).toContainText('Live smoke'); +}); + +test('an invalidated server session is recovered without stale identity', async ({ page }) => { + await signIn(page); + await expect(page.getByRole('table')).toContainText('Live smoke'); + // Invalidate server-side but leave the browser's old session cookie in place. + const redirect = appUrl('/login'); + const response = await page.request.get(`/api/v1/o/logout?${new URLSearchParams({ redirect })}`); + expect(response.ok()).toBe(true); + await page.reload(); + await expect(page).toHaveURL((url) => atLogin(url) && url.searchParams.has('next')); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page.getByRole('table')).toContainText('Live smoke'); +}); + +test('logs filter and keyboard event details use ingested data', async ({ page }) => { + await signIn(page); + await expect(page.getByRole('table')).toContainText('Live smoke'); + await page.getByRole('button', { name: 'Add filter', exact: true }).click(); + const filter = page.getByRole('dialog', { name: 'Add filter' }); + await filter.getByLabel('Field', { exact: true }).selectOption('level'); + await filter.getByLabel('Value', { exact: true }).fill('ERROR'); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + await expect(page.getByRole('table')).toContainText('Live smoke payment timeout'); + await expect(page.getByRole('table')).not.toContainText('Live smoke request accepted'); + await expect(page.locator('[data-filter-pill-id]')).toContainText('ERROR'); + const opener = page.getByRole('button', { name: 'Open event 1', exact: true }); + await opener.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Event details' })).toContainText('smoke-2'); + await page.keyboard.press('Escape'); + await expect(opener).toBeFocused(); +}); + +test('SQL executes on DataFusion and reports an actual query error', async ({ page }) => { + const sql = `SELECT * FROM "${dataset.replaceAll('"', '""')}" LIMIT 10`; + await signIn(page, `/sql-editor?query=${encodeURIComponent(sql)}`); + await page.getByRole('button', { name: 'Run query', exact: true }).click(); + await expect(page.getByRole('table')).toContainText('Live smoke'); + const editor = page.getByRole('textbox', { name: 'SQL query' }); + await editor.fill('SELECT * FROM "frontend_smoke_missing_table"'); + await editor.press('Control+Enter'); + await expect(page.getByRole('alert')).toContainText(/not found|does not exist/i); + await expect(page.getByRole('table')).toHaveCount(0); +}); + +test('dataset inventory and Arrow schema lead back to logs', async ({ page }) => { + await signIn(page, '/datasets'); + await page.getByRole('textbox', { name: 'Search datasets' }).fill(dataset); + await page.getByRole('button', { name: `View schema for ${dataset}` }).click(); + const schema = page.getByRole('dialog', { name: dataset, exact: true }); + await expect(schema.locator('[data-field-node="message"]')).toBeVisible(); + await expect(schema.locator('[data-field-node="request_id"]')).toBeVisible(); + await schema.getByRole('link', { name: 'Explore dataset' }).click(); + await expect(page).toHaveURL(appUrl(datasetPath)); + await expect(page.getByRole('table')).toContainText('Live smoke'); +}); + +test('SSO traverses the provider, real callback and signed-in UI', async ({ page, request }) => { + test.skip( + process.env.PARSEABLE_LIVE_OIDC !== 'true', + 'Enable the local OIDC fixture separately.', + ); + const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const role = await request.put('/api/v1/role/frontend-smoke-reader', { + headers: { Authorization: authorization }, + data: [{ privilege: 'reader', resource: { stream: dataset } }], + }); + expect(role.ok(), await role.text()).toBe(true); + await page.goto(app(`/login?next=${encodeURIComponent(datasetPath)}`)); + await page.getByRole('button', { name: 'Sign in with SSO', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Local mock identity provider' })).toBeVisible(); + await expect(page).toHaveURL( + (url) => + url.origin === `http://127.0.0.1:${process.env.MOCK_OIDC_PORT || '8251'}` && + url.pathname === '/authorize', + ); + const callback = page.waitForRequest((req) => new URL(req.url()).pathname === '/api/v1/o/code'); + await page.getByRole('button', { name: 'Sign in as Smoke SSO User' }).click(); + expect((await callback).isNavigationRequest()).toBe(true); + await expect(page).toHaveURL(appUrl(datasetPath)); + await expect(page.getByText('Smoke SSO User', { exact: true }).first()).toBeVisible(); + await expect(page.getByRole('table')).toContainText('Live smoke'); + // Reader accounts can inspect metadata, but cannot enumerate administrative roles. + const deniedRoles = await page.request.get('/api/v1/roles'); + expect(deniedRoles.status()).toBe(403); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page).toHaveURL(atLogin); + await expect(page.getByLabel('Username', { exact: true })).toBeVisible(); + // Re-enter SSO with any stale cookie still supplied by the backend logout response. + await page.getByRole('button', { name: 'Sign in with SSO', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Local mock identity provider' })).toBeVisible(); +}); + +test('an unconfigured OIDC server explains native login and keeps the return path', async ({ + page, +}) => { + test.skip( + process.env.PARSEABLE_LIVE_NO_OIDC !== 'true', + 'Run against a server started without OIDC options.', + ); + await page.goto(app(`/login?next=${encodeURIComponent(datasetPath)}`)); + await page.getByRole('button', { name: 'Sign in with SSO', exact: true }).click(); + await expect(page).toHaveURL((url) => url.pathname === app('/oidc-not-configured')); + await expect( + page.getByRole('heading', { name: 'Single sign-on is not configured' }), + ).toBeVisible(); + await page.getByLabel('Username', { exact: true }).fill(username); + await page.getByLabel('Password', { exact: true }).fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page).toHaveURL(appUrl(datasetPath)); + await expect(page.getByRole('table')).toContainText('Live smoke'); +}); diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts new file mode 100644 index 000000000..a918774f9 --- /dev/null +++ b/frontend/e2e/app.spec.ts @@ -0,0 +1,446 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +async function demo(page: Page, path = '/logs') { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto(path); +} + +test('a failed live connection remains visible until the user chooses demo', async ({ page }) => { + await page.route('**/api/v1/**', (route) => + route.fulfill({ status: 503, contentType: 'text/plain', body: 'Test server unavailable' }), + ); + await page.goto('/logs'); + await expect(page.getByRole('heading', { name: 'Could not load data' })).toBeVisible(); + await expect(page.getByText('Live server', { exact: true })).toBeVisible(); + await expect(page.getByRole('table')).toHaveCount(0); + await page.getByRole('button', { name: 'Explore demo data' }).click(); + await expect(page.getByText('Demo data', { exact: true })).toBeVisible(); + await expect(page.getByLabel('Dataset', { exact: true })).toHaveValue('application_logs'); + await expect(page.getByRole('table')).toBeVisible(); +}); + +test('log filters, columns, event details and pagination compose', async ({ page }) => { + await demo(page); + await expect(page.getByRole('table')).toBeVisible(); + await expect(page.getByText('Page 1 of 4', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Next page' }).click(); + await expect(page.getByText('Page 2 of 4', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Add filter', exact: true }).click(); + const filter = page.getByRole('dialog', { name: 'Add filter' }); + await filter.getByLabel('Field', { exact: true }).selectOption('level'); + await filter.getByLabel('Value', { exact: true }).fill('ERROR'); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + await expect(page.getByTestId('filter-pill-value')).toHaveText('ERROR'); + const rows = page.getByRole('table').locator('tbody tr'); + await expect(rows).toHaveCount(8); + for (const row of await rows.all()) + await expect(row.getByText('ERROR', { exact: true })).toBeVisible(); + await page.locator('[data-field-node="host"]').click(); + await expect(page.getByRole('columnheader', { name: 'host', exact: true })).toBeVisible(); + await page.locator('[data-field-node="host"]').click(); + await expect(page.getByRole('columnheader', { name: 'host', exact: true })).toHaveCount(0); + const opener = page.getByRole('button', { name: 'Open event 1', exact: true }); + await opener.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('dialog', { name: 'Event details' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(opener).toBeFocused(); + await page.getByRole('button', { name: 'Remove filter level = ERROR' }).click(); + await expect(page.getByTestId('filter-pill-value')).toHaveCount(0); + await expect(page.getByText('Page 1 of 4', { exact: true })).toBeVisible(); +}); + +test('SQL editor runs a real query against the explicit demo adapter', async ({ page }) => { + await demo(page, '/sql-editor'); + await expect(page.getByRole('heading', { name: 'Ready when you are' })).toBeVisible(); + const editor = page.getByRole('textbox', { name: 'SQL query' }); + await editor.fill('SELECT * FROM "application_logs" WHERE "level" = \'ERROR\' LIMIT 3'); + await editor.press('Control+Enter'); + await expect(page.getByRole('table').locator('tbody tr')).toHaveCount(3); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export JSON' }).click(); + expect((await download).suggestedFilename()).toBe('query-results.json'); + await editor.fill('SELECT COUNT(*) FROM "application_logs"'); + await page.getByRole('button', { name: 'Run query' }).click(); + await expect(page.getByRole('alert')).toContainText('Demo SQL supports'); + await expect(page.getByRole('table')).toHaveCount(0); +}); + +test('narrow-screen navigation retains accessible links and contained layout', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await demo(page); + await page.getByTestId('sidebar-datasets').click(); + await expect(page.getByRole('heading', { name: 'Datasets', exact: true })).toBeVisible(); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); +}); + +test('a dashboard persists locally and deletion requires confirmation', async ({ page }) => { + await demo(page, '/dashboards'); + await page.getByRole('button', { name: 'Create dashboard', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Create dashboard' }); + await dialog.getByLabel('Dashboard name').fill('Production signals'); + await dialog.getByLabel('Description').fill('Application event volume'); + await dialog.getByLabel('Dataset', { exact: true }).selectOption('application_logs'); + await dialog.getByRole('button', { name: 'Create dashboard', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Production signals', exact: true }), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole('heading', { name: 'Production signals', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Create dashboard', exact: true }).click(); + await dialog.getByLabel('Dashboard name').fill(' production SIGNALS '); + await expect(dialog.getByLabel('Dashboard name')).toHaveAccessibleDescription( + 'A dashboard with this name already exists.', + ); + await expect( + dialog.getByRole('button', { name: 'Create dashboard', exact: true }), + ).toBeDisabled(); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Delete dashboard Production signals' }).click(); + await expect(page.getByRole('dialog', { name: 'Delete dashboard?' })).toBeVisible(); + await page.keyboard.press('Escape'); + await expect( + page.getByRole('heading', { name: 'Production signals', exact: true }), + ).toBeVisible(); + await page.getByRole('button', { name: 'Delete dashboard Production signals' }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Delete dashboard', exact: true }) + .click(); + await expect(page.getByRole('heading', { name: 'Production signals', exact: true })).toHaveCount( + 0, + ); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Production signals', exact: true })).toHaveCount( + 0, + ); +}); + +test('dashboards saved with duplicate titles get distinct delete labels', async ({ page }) => { + await page.addInitScript(() => { + const dashboard = { description: '', dataset: 'application_logs', title: 'API errors' }; + localStorage.setItem( + 'parseable-dashboards-v1-demo', + JSON.stringify([ + { ...dashboard, id: 'a' }, + { ...dashboard, id: 'b' }, + ]), + ); + }); + await demo(page, '/dashboards'); + await expect( + page.getByRole('button', { name: 'Delete dashboard API errors', exact: true }), + ).toHaveCount(1); + await page.getByRole('button', { name: 'Delete dashboard API errors (2)', exact: true }).click(); + await page + .getByRole('dialog') + .getByRole('button', { name: 'Delete dashboard', exact: true }) + .click(); + await expect(page.getByRole('heading', { name: 'API errors (2)', exact: true })).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'API errors', exact: true })).toBeVisible(); +}); + +test('dataset search and schema inspection lead to the matching explorer', async ({ page }) => { + await demo(page, '/datasets'); + await page.getByRole('textbox', { name: 'Search datasets' }).fill('api_logs'); + await expect(page.getByRole('heading', { name: 'application_logs', exact: true })).toHaveCount(0); + await page.getByRole('button', { name: 'View schema for api_logs' }).click(); + const sheet = page.getByRole('dialog', { name: 'api_logs', exact: true }); + await expect(sheet.locator('[data-field-node="p_timestamp"]')).toBeVisible(); + await expect(sheet.locator('[data-field-node="trace_id"]')).toBeVisible(); + await sheet.getByRole('link', { name: 'Explore dataset' }).click(); + await expect(page).toHaveURL(/\/logs\/explore\/api_logs$/); + await expect(page.getByLabel('Dataset', { exact: true })).toHaveValue('api_logs'); + await expect(page.getByRole('table')).toBeVisible(); +}); + +test('library dialogs restore focus, contain keyboard navigation, and tabs use arrow keys', async ({ + page, +}) => { + await demo(page, '/components'); + const opener = page.getByRole('button', { name: 'Open dialog', exact: true }); + await opener.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Example dialog' }); + await expect(dialog).toBeVisible(); + for (let index = 0; index < 6; index++) { + await page.keyboard.press('Tab'); + expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); + } + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); + await page.getByRole('tab', { name: 'Overview', exact: true }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('tab', { name: 'Details', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.getByRole('tabpanel')).toContainText('Each panel has a clear purpose.'); + await page.getByRole('button', { name: 'Use dark theme' }).click(); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(results.violations).toEqual([]); +}); + +for (const route of ['/logs', '/sql-editor', '/datasets', '/dashboards', '/components']) { + test(`accessibility smoke: ${route}`, async ({ page }) => { + await demo(page, route); + await expect(page.locator('main h1')).toBeVisible(); + await expect(page.getByText('Loading data…', { exact: true })).toHaveCount(0); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(results.violations).toEqual([]); + }); +} + +test('native login switches from demo to live and sends backend query contracts', async ({ + page, +}) => { + let authenticated = false; + let queryBody: Record | undefined; + await page.route('**/api/v1/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === '/api/v1/o/login') { + expect(request.headers().authorization).toBe( + `Basic ${Buffer.from('reader:test-password').toString('base64')}`, + ); + expect(new URL(request.url()).searchParams.get('redirect')).toBe( + 'http://127.0.0.1:5173/logs', + ); + authenticated = true; + return route.fulfill({ status: 200, body: 'ok' }); + } + if (path === '/api/v1/o/logout') { + expect(request.isNavigationRequest()).toBe(true); + authenticated = false; + return route.fulfill({ status: 302, headers: { Location: '/login' } }); + } + expect(request.headers().authorization).toBeUndefined(); + if (!authenticated) return route.fulfill({ status: 401, body: 'Sign in required' }); + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'server_logs' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: [{ name: 'p_timestamp' }, { name: 'message' }] } }); + if (path === '/api/v1/query') { + queryBody = request.postDataJSON(); + return route.fulfill({ + json: [{ p_timestamp: new Date().toISOString(), message: 'Live contract fixture' }], + }); + } + return route.fulfill({ status: 404 }); + }); + await demo(page); + await page.getByRole('button', { name: 'Connection settings' }).click(); + const dialog = page.getByRole('dialog', { name: 'Connect to Parseable' }); + await dialog.getByLabel('Username').fill('reader'); + await dialog.getByLabel('Password', { exact: true }).fill('test-password'); + await dialog.getByRole('button', { name: 'Connect', exact: true }).click(); + await expect(page.getByText('Live server', { exact: true })).toBeVisible(); + await expect(page.getByRole('table')).toContainText('Live contract fixture'); + expect(queryBody?.query).toContain('FROM "server_logs"'); + expect(queryBody?.sendNull).toBe(true); + expect(queryBody?.startTime).toEqual(expect.any(String)); + expect(queryBody?.endTime).toEqual(expect.any(String)); + expect( + await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })), + ).not.toContain('test-password'); + await page.getByRole('button', { name: 'Connection settings' }).click(); + await page.getByRole('button', { name: 'Sign out of server' }).click(); + await expect(page.getByRole('table')).toHaveCount(0); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByRole('heading', { name: 'Log in to your account' })).toBeVisible(); +}); + +test('mobile navigation has names for assistive technology', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await demo(page, '/'); + await page.getByRole('link', { name: 'Logs', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Logs', exact: true })).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(results.violations).toEqual([]); +}); + +test('dark SQL editor retains accessible syntax contrast', async ({ page }) => { + await demo(page, '/sql-editor'); + await page.getByRole('textbox', { name: 'SQL query' }).waitFor(); + await page.getByRole('button', { name: 'Use dark theme' }).click(); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(results.violations).toEqual([]); +}); + +test('event details do not carry a status over to the next event', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await demo(page); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + const sheet = page.getByRole('dialog', { name: 'Event details' }); + await sheet.getByRole('button', { name: 'Copy JSON' }).click(); + await expect(sheet.getByRole('status')).toHaveText('Event copied'); + await sheet.getByRole('button', { name: 'Include level in filter' }).click(); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + await expect(sheet.getByRole('status')).toHaveText(''); + await sheet.getByRole('button', { name: 'Copy JSON' }).click(); + await expect(sheet.getByRole('status')).toHaveText('Event copied'); + await page.keyboard.press('Escape'); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + await expect(sheet.getByRole('status')).toHaveText(''); +}); + +test('switching dataset never queries it with the previous filters', async ({ page }) => { + const queries: string[] = []; + await page.route('**/api/v1/**', async (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (path === '/api/v1/logstream') + return route.fulfill({ json: [{ name: 'a_logs' }, { name: 'b_logs' }] }); + if (path.endsWith('/schema')) + return route.fulfill({ json: { fields: [{ name: 'level' }, { name: 'message' }] } }); + if (path === '/api/v1/query') { + queries.push(request.postDataJSON().query); + return route.fulfill({ json: [{ level: 'ERROR', message: 'fixture' }] }); + } + return route.fulfill({ status: 404 }); + }); + await page.goto('/logs/explore/a_logs'); + await expect(page.getByRole('table')).toBeVisible(); + await page.getByRole('button', { name: 'Add filter', exact: true }).click(); + const filter = page.getByRole('dialog', { name: 'Add filter' }); + await filter.getByLabel('Field', { exact: true }).selectOption('level'); + await filter.getByLabel('Value', { exact: true }).fill('ERROR'); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + await expect + .poll(() => queries.some((q) => q.includes('a_logs') && q.includes('ERROR'))) + .toBe(true); + await page.getByLabel('Search messages').fill('needle'); + await expect + .poll(() => queries.some((q) => q.includes('a_logs') && q.includes('needle'))) + .toBe(true); + await page.getByLabel('Dataset', { exact: true }).selectOption('b_logs'); + await expect.poll(() => queries.some((q) => q.includes('b_logs'))).toBe(true); + await expect(page.getByTestId('filter-pill-value')).toHaveCount(0); + await expect(page.getByLabel('Search messages')).toHaveValue(''); + expect( + queries.filter((q) => q.includes('b_logs') && (q.includes('ERROR') || q.includes('needle'))), + ).toEqual([]); +}); + +test('a copy that finishes after the sheet reopens is not reported', async ({ page }) => { + await demo(page); + await page.evaluate(() => { + const pending: (() => void)[] = []; + Object.assign(window, { finishCopy: () => pending.splice(0).forEach((done) => done()) }); + navigator.clipboard.writeText = () => new Promise((done) => pending.push(done)); + }); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + const sheet = page.getByRole('dialog', { name: 'Event details' }); + await sheet.getByRole('button', { name: 'Copy JSON' }).click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + await page.evaluate(() => (window as unknown as { finishCopy: () => void }).finishCopy()); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expect(sheet.getByRole('status')).toHaveText(''); +}); + +test('filter chips on the same field have distinct remove labels', async ({ page }) => { + await demo(page); + await expect(page.getByRole('table')).toBeVisible(); + const filter = page.getByRole('dialog', { name: 'Add filter' }); + for (const [operator, value] of [ + ['!=', 'DEBUG'], + ['!=', 'INFO'], + ]) { + await page.getByRole('button', { name: 'Add filter', exact: true }).click(); + await filter.getByLabel('Field', { exact: true }).selectOption('level'); + await filter.getByLabel('Operator', { exact: true }).selectOption(operator); + await filter.getByLabel('Value', { exact: true }).fill(value); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + } + await page.getByRole('button', { name: 'Add filter', exact: true }).click(); + await filter.getByLabel('Field', { exact: true }).selectOption('level'); + await filter.getByLabel('Operator', { exact: true }).selectOption('!='); + await filter.getByLabel('Value', { exact: true }).fill('DEBUG'); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + await expect(page.getByTestId('filter-pill-value')).toHaveText(['DEBUG', 'INFO']); + await expect(page.getByRole('button', { name: 'Remove filter level != DEBUG' })).toHaveCount(1); + await page.getByRole('button', { name: 'Remove filter level != INFO' }).click(); + await expect(page.getByTestId('filter-pill-value')).toHaveText(['DEBUG']); +}); + +test('a filter applied while results reload keeps the page and a real field', async ({ page }) => { + const errors: Error[] = []; + page.on('pageerror', (error) => errors.push(error)); + let release = () => {}; + const held = new Promise((done) => (release = done)); + let queries = 0; + await page.route('**/api/v1/**', async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === '/api/v1/logstream') return route.fulfill({ json: [{ name: 'server_logs' }] }); + // An empty schema leaves the field list to the returned rows. + if (path.endsWith('/schema')) return route.fulfill({ json: { fields: [] } }); + if (path === '/api/v1/query') { + if (++queries > 1) await held; + return route.fulfill({ json: [{ level: 'ERROR', message: 'fixture' }] }); + } + return route.fulfill({ status: 404 }); + }); + await page.clock.install(); + await page.goto('/logs'); + await expect(page.getByRole('table')).toContainText('fixture'); + // Hold the search debounce so the dialog opens before the reload starts. + await page.clock.pauseAt(Date.now() + 60_000); + await page.getByLabel('Search messages').fill('needle'); + await page.getByTestId('add-filter-button').click(); + const filter = page.getByRole('dialog', { name: 'Add filter' }); + await filter.getByLabel('Value', { exact: true }).fill('ERROR'); + await page.clock.runFor(300); + await expect.poll(() => queries).toBe(2); + await filter.getByRole('button', { name: 'Apply filter' }).click(); + await page.clock.resume(); + await expect(page.getByRole('heading', { name: 'Logs', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Remove filter level = ERROR' })).toBeVisible(); + release(); + await expect(page.getByRole('table')).toContainText('fixture'); + expect(errors).toEqual([]); +}); + +test('an earlier copy finishing late keeps the reopened sheet status', async ({ page }) => { + await demo(page); + await page.evaluate(() => { + const pending: (() => void)[] = []; + Object.assign(window, { finishCopy: (index: number) => pending[index]() }); + navigator.clipboard.writeText = () => new Promise((done) => pending.push(done)); + }); + const finishCopy = (index: number) => + page.evaluate( + (i) => (window as unknown as { finishCopy: (i: number) => void }).finishCopy(i), + index, + ); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + const sheet = page.getByRole('dialog', { name: 'Event details' }); + await sheet.getByRole('button', { name: 'Copy JSON' }).click(); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + await sheet.getByRole('button', { name: 'Copy JSON' }).click(); + await finishCopy(1); + await expect(sheet.getByRole('status')).toHaveText('Event copied'); + await finishCopy(0); + await page.evaluate(() => new Promise(requestAnimationFrame)); + await expect(sheet.getByRole('status')).toHaveText('Event copied'); +}); diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts new file mode 100644 index 000000000..548edce6b --- /dev/null +++ b/frontend/e2e/auth.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +test('login has working password visibility and administrator recovery', async ({ page }) => { + await page.goto('/login'); + await page.getByLabel('Password', { exact: true }).fill('private'); + await expect(page.getByLabel('Password', { exact: true })).toHaveAttribute('type', 'password'); + await page.getByRole('button', { name: 'Show password' }).click(); + await expect(page.getByLabel('Password', { exact: true })).toHaveAttribute('type', 'text'); + await page.getByRole('button', { name: 'Hide password' }).click(); + await page.getByRole('button', { name: 'Forgot password?' }).click(); + const dialog = page.getByRole('dialog', { name: 'Reset password' }); + await expect(dialog).toContainText('Please contact admin to reset your password'); + await dialog.getByRole('button', { name: 'Got it' }).click(); + await expect(dialog).not.toBeVisible(); + expect( + (await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze()) + .violations, + ).toEqual([]); +}); + +test('live 401 returns to the requested application path after native sign in', async ({ + page, +}) => { + let signedIn = false; + await page.route('**/api/v1/**', (route) => { + const request = route.request(); + const url = new URL(request.url()); + if (url.pathname === '/api/v1/o/login') { + expect(url.searchParams.get('redirect')).toBe('http://127.0.0.1:5173/datasets?view=list'); + signedIn = true; + return route.fulfill({ status: 200, body: 'ok' }); + } + return route.fulfill( + signedIn ? { json: [{ name: 'server_logs' }] } : { status: 401, body: 'Session expired' }, + ); + }); + await page.goto('/datasets?view=list'); + await expect(page).toHaveURL(/\/login\?next=/); + await page.getByLabel('Username', { exact: true }).fill('reader'); + await page.getByLabel('Password', { exact: true }).fill('secret'); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page).toHaveURL('/datasets?view=list'); + await expect(page.getByText('server_logs', { exact: true })).toBeVisible(); +}); + +test('OAuth is a document navigation with a safe same-origin redirect', async ({ page }) => { + await page.route('**/api/v1/o/login?**', (route) => { + expect(route.request().isNavigationRequest()).toBe(true); + expect(new URL(route.request().url()).searchParams.get('redirect')).toBe( + 'http://127.0.0.1:5173/', + ); + return route.fulfill({ contentType: 'text/html', body: '

Identity provider handoff

' }); + }); + await page.goto('/login?next=https%3A%2F%2Fevil.example%2F'); + await page.getByRole('button', { name: 'Sign in with SSO' }).click(); + await expect(page.getByRole('heading', { name: 'Identity provider handoff' })).toBeVisible(); +}); + +test('OAuth not configured preserves native login and the intended route', async ({ page }) => { + await page.addInitScript(() => sessionStorage.setItem('parseable-return-path', '/sql-editor')); + await page.goto('/oidc-not-configured'); + await expect( + page.getByRole('heading', { name: 'Single sign-on is not configured' }), + ).toBeVisible(); + await expect(page.getByLabel('Username', { exact: true })).toBeVisible(); + await page.getByRole('link', { name: 'Back to native sign in' }).click(); + await expect(page).toHaveURL('/login?next=%2Fsql-editor'); +}); + +test('forbidden resources expose permission recovery without a demo action', async ({ page }) => { + await page.route('**/api/v1/**', (route) => + route.fulfill({ status: 403, body: 'Dataset access denied' }), + ); + await page.goto('/logs'); + await expect(page.getByRole('heading', { name: 'Permission denied' })).toBeVisible(); + await expect(page.getByRole('alert')).toContainText('Ask your administrator'); + await expect(page.getByRole('button', { name: 'Explore demo data' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Sign out', exact: true })).toBeVisible(); + await expect(page).toHaveURL('/logs'); +}); + +test('stale sessions are cleared before OAuth retry', async ({ page, context }) => { + await context.addCookies([ + { name: 'session', value: 'expired', url: 'http://127.0.0.1:5173' }, + { name: 'username', value: 'old-reader', url: 'http://127.0.0.1:5173' }, + ]); + await page.route('**/api/v1/**', (route) => { + if (new URL(route.request().url()).pathname === '/api/v1/o/login') { + expect(route.request().headers().cookie || '').not.toContain('session=expired'); + return route.fulfill({ contentType: 'text/html', body: '

OAuth retry

' }); + } + return route.fulfill({ status: 401, body: 'Expired' }); + }); + await page.goto('/logs'); + await expect(page.getByRole('heading', { name: 'Log in to your account' })).toBeVisible(); + await page.getByRole('button', { name: 'Sign in with SSO' }).click(); + await expect(page.getByRole('heading', { name: 'OAuth retry' })).toBeVisible(); +}); diff --git a/frontend/e2e/explorer-parity.spec.ts b/frontend/e2e/explorer-parity.spec.ts new file mode 100644 index 000000000..654d64851 --- /dev/null +++ b/frontend/e2e/explorer-parity.spec.ts @@ -0,0 +1,116 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +async function demo(page: Page, path = '/logs') { + await page.addInitScript(() => sessionStorage.setItem('parseable-mode', 'demo')); + await page.goto(path); +} + +test('filter editing and clearing keeps the operator and value in the query', async ({ page }) => { + await demo(page); + await page.getByTestId('add-filter-button').click(); + let dialog = page.getByRole('dialog', { name: 'Add filter' }); + await dialog.getByLabel('Field', { exact: true }).selectOption('level'); + await dialog.getByLabel('Value', { exact: true }).fill('ERROR'); + await dialog.getByRole('button', { name: 'Apply filter' }).click(); + await expect(page.getByRole('table').locator('tbody tr')).toHaveCount(8); + await page.getByRole('button', { name: 'Edit filter level' }).click(); + dialog = page.getByRole('dialog', { name: 'Edit filter' }); + await dialog.getByLabel('Operator', { exact: true }).selectOption('!='); + await dialog.getByRole('button', { name: 'Apply filter' }).click(); + await expect(page.getByRole('button', { name: 'Remove filter level != ERROR' })).toBeVisible(); + await expect(page.getByRole('table').locator('tbody')).not.toContainText('ERROR'); + await page.getByRole('button', { name: 'Clear all', exact: true }).click(); + await expect(page.getByTestId('filter-pill-value')).toHaveCount(0); + await expect(page.getByText('Page 1 of 4', { exact: true })).toBeVisible(); +}); + +test('column picker, sorting and row wrapping retain keyboard accessible results', async ({ + page, +}) => { + await demo(page); + await page.getByRole('button', { name: 'Add columns', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Add columns' }); + await dialog.getByLabel('Search columns').fill('host'); + await dialog.getByRole('checkbox', { name: 'host', exact: true }).check(); + await dialog.getByRole('button', { name: 'Done' }).click(); + await expect(page.getByRole('columnheader', { name: 'host', exact: true })).toBeVisible(); + const sort = page.getByRole('button', { name: 'Sort host', exact: true }); + await sort.focus(); + await page.keyboard.press('Enter'); + await expect(page.getByRole('columnheader', { name: 'host', exact: true })).toHaveAttribute( + 'aria-sort', + 'ascending', + ); + await sort.click(); + await expect(page.getByRole('columnheader', { name: 'host', exact: true })).toHaveAttribute( + 'aria-sort', + 'descending', + ); + const wrap = page.getByRole('button', { name: 'Toggle row wrapping' }); + await wrap.click(); + await expect(wrap).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('table').locator('tbody td').last()).toHaveCSS( + 'white-space', + 'pre-wrap', + ); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export CSV', exact: true }).click(); + expect((await download).suggestedFilename()).toBe('query-results.csv'); +}); + +test('absolute range validates before applying and cancel preserves selection', async ({ + page, +}) => { + await demo(page); + await page.getByRole('button', { name: 'Choose absolute time range' }).click(); + const dialog = page.getByRole('dialog', { name: 'Time range' }); + await dialog.getByLabel('From', { exact: true }).fill('2026-10-06T12:00'); + await dialog.getByLabel('To', { exact: true }).fill('2026-10-06T11:00'); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(dialog.getByRole('alert')).toHaveText('From must be before To.'); + await dialog.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(page.getByRole('combobox', { name: 'Time range', exact: true })).toHaveValue('1h'); + await page.getByRole('button', { name: 'Choose absolute time range' }).click(); + await dialog.getByLabel('From', { exact: true }).fill('2020-01-01T00:00'); + await dialog.getByLabel('To', { exact: true }).fill('2020-01-02T00:00'); + await dialog.getByRole('button', { name: 'Apply', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'No matching events' })).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Time range', exact: true })).toHaveValue( + 'custom', + ); +}); + +test('event field search, JSON view, and exclusion compose without losing modal focus', async ({ + page, +}) => { + await demo(page); + await page.getByRole('button', { name: 'Open event 1', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Event details' }); + await dialog.getByLabel('Search event fields').fill('level'); + await expect(dialog.getByRole('button', { name: 'Exclude level from filter' })).toBeVisible(); + await expect(dialog.getByRole('button', { name: 'Include host in filter' })).toHaveCount(0); + await dialog.getByRole('tab', { name: 'JSON', exact: true }).click(); + await expect(dialog.getByRole('tabpanel')).toContainText('p_timestamp'); + await dialog.getByRole('tab', { name: 'Fields', exact: true }).click(); + const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze(); + expect(axe.violations).toEqual([]); + await dialog.getByRole('button', { name: 'Exclude level from filter' }).click(); + await expect(dialog).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Remove filter level != ERROR' })).toBeVisible(); +}); + +test('SQL dataset selection quotes the chosen dataset and runs its query', async ({ page }) => { + await demo(page, '/sql-editor'); + const sidebar = page.getByRole('complementary', { name: 'SQL datasets' }); + await sidebar.getByLabel('Search SQL datasets').fill('api_logs'); + await sidebar.getByRole('button', { name: 'api_logs', exact: true }).click(); + await expect(page.getByRole('textbox', { name: 'SQL query' })).toContainText('FROM "api_logs"'); + await page.getByRole('button', { name: 'Run query' }).click(); + await expect(page.getByRole('table')).toBeVisible(); + await page.getByRole('button', { name: 'Toggle row wrapping' }).click(); + await expect(page.getByRole('button', { name: 'Toggle row wrapping' })).toHaveAttribute( + 'aria-pressed', + 'true', + ); +}); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 000000000..b051bb456 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,2 @@ + +Parseable · Observability
diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 000000000..625b29e14 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5643 @@ +{ + "name": "@parseable/frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@parseable/frontend", + "version": "0.1.0", + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.5", + "@radix-ui/react-dialog": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.22", + "codemirror": "^6.0.2", + "lucide-react": "^1.52.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@playwright/test": "^1.63.0", + "@storybook/addon-docs": "^10.6.1", + "@storybook/react-vite": "^10.6.1", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "prettier": "^3.9.9", + "storybook": "^10.6.1", + "typescript": "^7.0.2", + "vite": "^8.3.3", + "vitest": "^5.0.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@axe-core/playwright": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz", + "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.13.0" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz", + "integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz", + "integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz", + "integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.13", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz", + "integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.7.0.tgz", + "integrity": "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^13.0.1", + "react-docgen-typescript": "^2.2.2" + }, + "peerDependencies": { + "typescript": ">= 4.3.x", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz", + "integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz", + "integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz", + "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==", + "license": "MIT" + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.5.tgz", + "integrity": "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.127.0.tgz", + "integrity": "sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.127.0.tgz", + "integrity": "sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.127.0.tgz", + "integrity": "sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.127.0.tgz", + "integrity": "sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.127.0.tgz", + "integrity": "sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.127.0.tgz", + "integrity": "sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.127.0.tgz", + "integrity": "sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.127.0.tgz", + "integrity": "sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.127.0.tgz", + "integrity": "sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.127.0.tgz", + "integrity": "sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.127.0.tgz", + "integrity": "sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.127.0.tgz", + "integrity": "sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.127.0.tgz", + "integrity": "sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.127.0.tgz", + "integrity": "sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.127.0.tgz", + "integrity": "sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.127.0.tgz", + "integrity": "sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.127.0.tgz", + "integrity": "sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.127.0.tgz", + "integrity": "sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.127.0.tgz", + "integrity": "sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.127.0.tgz", + "integrity": "sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.2.tgz", + "integrity": "sha512-xQoCRv+gKax9KTdwdaQNnAFOai8neay7g3jExDIORzhbrejwGSJaZNTdOJHR5ziLg2joMxOCMOFMo4zuxza2uQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.2.tgz", + "integrity": "sha512-HF5oiE2L05yInPYCFD/4uxSrEZW4SuIfn99Y6L1xnJnzl066JR+MJs2rIdstw8A2MPlAKH+13dpFPNycjqzvGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.2.tgz", + "integrity": "sha512-UX4u49CVCAD8QZNELaW8eMGgMAGwFWYEPbvNsh+3r/gs4NX3KfpiACMVwRQT0EuH3uat9hM5Zl+Ppm9pJD8tgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.2.tgz", + "integrity": "sha512-J9xPx7YBkrRmJ+xl561ztnMWEc1aOyjEIxBiGX1dVb3u7bGSnfObfcZk+Pd+uM0HZAPNsQ1xvD8j52A/uOSqNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.2.tgz", + "integrity": "sha512-fRlt7OvSaQkWj6+EDTVxawVxOlqJB2QSnBfkeCyK4RTvsGctbw3BiH2Tb7DzMs7bikc4BRBpvWP5zF9K8b54Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.2.tgz", + "integrity": "sha512-gK+vPUcPQITkGwBKpZGrcDHSlU6eDGl7AQacxS2CEKAZIBHWkOVFeJwLZ4tYnA1acJqRM5lt7yYwPCVqGHIJ7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.2.tgz", + "integrity": "sha512-L/Rgas7SrOKy/z7IH+HxSFRqVO4PuLDKLEGKvnhoCBBq3UJ0YzGBou3qMzaAGgkJwsIvX2pBF+7ojzfUhLiZxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.2.tgz", + "integrity": "sha512-CUEYvlX1Fk7E9kUMzuswru1J9HLxMwnpDeQGjQuI4ZH+iNCoa2X9T+pvyzrbsgl7WnIeTFTlNlHsRfVdf5g9/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.2.tgz", + "integrity": "sha512-ViN1ZibQyxwC67GpoP33oo+S9UyUnkog13vzQb9+v9bCNvrVzJuk0MRdacjtv/9xfRMVF/eqHFyl8YOeykI10Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.2.tgz", + "integrity": "sha512-CU1sCqWnhGqYD5I1HedHk5pujrn7ssDkNB/AEQd/pd3D/EojVSgJUlpbafwIbyhia3PgIfkvdFpRPWSALzVumA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.2.tgz", + "integrity": "sha512-jWVyZtIHca4Gb96x7dag+y69vlei7ffjrsveLkmf2ZhqEAz6ZSBnY1GWvgZUaZlwZP62A3xD092BW97Q5VGc+g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.2.tgz", + "integrity": "sha512-LF29obFqNgBUgDX7rmUK7M4D0JQG5LxhYzn3xXmECcHU9aQAdWG7NiY052qybtesEdwHQXKNTWYQ7mTsybNvWg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.2.tgz", + "integrity": "sha512-+NYcm+cCHBbtdQQ3A4phQTSuVRYnNHz7wrl9XRAPEovcdoqi0mb1K5ZOl+jN54ZD+q1zz3V0vltbFJmzecJKmw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.2.tgz", + "integrity": "sha512-UQqZDdG2r2HhAOsZEgufkIWHPQ886IUyuJQkoZByvzhW8j51R4UNzGBJFkTiTnLhQnggwJRdJgFWK4uY6ZVIMw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.2.tgz", + "integrity": "sha512-3Q9PMRjalWkT6NZ4jfujuqTCFwoWErg3y3BnOgb544B8IMw4PktiwWOigMfOHNRLMghZeJ7hpfpZf4CP7rV7Og==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.2.tgz", + "integrity": "sha512-Eljeq3ndtyKhM+Es8LITi4Zl2htzuRZcrPMF3kMCsrILztvU6AjZ3FuEhHHKobPUB9rMpzLpJP5bDqXI7r+iog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.2.tgz", + "integrity": "sha512-HGDbNsIywqc4LxU38+CTJNnB/6BF7rheWOJ259b4eE0aEnelYblC8x+1tEd63bp31fYne63RQz9Jb4yVnC7Yig==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.0", + "@emnapi/runtime": "1.11.0", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz", + "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.2.tgz", + "integrity": "sha512-iWx25CBEgH49iE9q5coEGI/jb1jl5kkCY9z6U5Og67xCkQ/WFMDc2J5U78+AE91SUxM2NqSLhJC8/PLfWnImww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.2.tgz", + "integrity": "sha512-VPoCAhKvCQTlG7vxqaBXcmuvbh77BfnGXj8g0pbvVXpm1F/R8rDVwqIWcEbMzrI1JvJlm8v7T9uMVQb6UctMRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz", + "integrity": "sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.16.tgz", + "integrity": "sha512-yEQrjfI5eBbeYEJXOASpm+gs9eNHY8+UjkZ81GzcuwdLrSMOlEM8jnGoGdeGo92oiBMkuwZUhHTsnsrDiJLcyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.2.tgz", + "integrity": "sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.2.0.tgz", + "integrity": "sha512-l5CX8HdNL+9cc4s7HkNyplumSA3FIx68sg9dttBHKr286fRBwq1MBniDVTeI/tkof92/APntMQvC1YG1Khb0Cg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-dismissable-layer": "1.1.20", + "@radix-ui/react-focus-guards": "1.1.6", + "@radix-ui/react-focus-scope": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.1.18", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-slot": "1.4.0", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.5.tgz", + "integrity": "sha512-/NlO/rOqQabmg+GGr86vCmbJVhqTv85NfM9g6Gknf/JVygXeZ6e6HM/RW1omerW9HsFtecwNqgEAKV6UqDsvpA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.20.tgz", + "integrity": "sha512-35WHRrycfnj2m4LvPiKMKHGLM3Y2WvUA744CkRdxjs1BGDX1c8PVMfmiSGTUwnHRbqdtIoxveYTXvcJSt7VfgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.6.tgz", + "integrity": "sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.2.0.tgz", + "integrity": "sha512-I5S7vsAOozx2lWSBAyuXjWIJ/A8qx4fjbv6115WoaRYQSb1yZDaeNHonUAQzXyE8oD+0whJHbQRl4HIE2gQ0Xw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.18.tgz", + "integrity": "sha512-ygFIJ+QXNKf72AAq8x3hd22+ws7pYpmc1S0E4kW0Je+qD4qEb3FvxeKVQRGrVDMOkQaYfsB+Qs+KDh81a1gAOw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.11.tgz", + "integrity": "sha512-RzcaSw5aKl0OPhBJ1wbolKSF6nVvN+0o/QLeIVq+09npiPdnCkPggy2k6gI6yetxl4ZyrlBPYFgCB8BQJZwXcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.4.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.20.tgz", + "integrity": "sha512-+ZjbkU21fTSJdn4nq/IFKUdKS+JSZHORBZ8ma1f6YpOMNzRt0xf3sVAWVK/+ktHCajPy4HYDsyHXZIZmOMxzIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-collection": "1.1.16", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-use-callback-ref": "1.1.4", + "@radix-ui/react-use-controllable-state": "1.2.6", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.4.0.tgz", + "integrity": "sha512-IuMVHZ+Ah5fkctu3X3qeFrV/ZrBcuWuDTFUUPfS/L5hvoTjzhp/HdWGE14CsQombPdEidsa33HVlYZXi49PZ1Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.22.tgz", + "integrity": "sha512-GmVH+P3wx1ZPMuH7WajQzygZIbqgnsb5h8z4Ow3sQMuDqgcqISSBNom6MLVNJvfrkuJTtqfXc3ZolHjX+w0CHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-context": "1.2.2", + "@radix-ui/react-direction": "1.1.5", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.1.11", + "@radix-ui/react-roving-focus": "1.1.20", + "@radix-ui/react-use-controllable-state": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.4.tgz", + "integrity": "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.6.tgz", + "integrity": "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.7", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@storybook/addon-docs": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-10.6.1.tgz", + "integrity": "sha512-31PXR+MCWfI5YHKKah6tWnbV1FJWA9qbTHxtvpszPLd8FBekXtPOnmyYOpFF9CfciBF9bkeJFdFanJvUCjWbjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mdx-js/react": "^3.0.0", + "@storybook/icons": "^2.0.2", + "@storybook/react-dom-shim": "10.6.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "ts-dedent": "^2.0.0", + "unplugin": "^2.3.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.6.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@storybook/builder-vite": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-10.6.1.tgz", + "integrity": "sha512-kUMRRRS4MpJdxOLhrlQX3cpMYi2vVB3tIIRXyqWmAjVM6xT7OZ1SCQEMFPP+S3ToRqaYWZQb8aBqrJucbbYufA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ts-dedent": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "storybook": "^10.6.1", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@storybook/global": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", + "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@storybook/icons": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-2.1.0.tgz", + "integrity": "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@storybook/react": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@storybook/react/-/react-10.6.1.tgz", + "integrity": "sha512-t0QIzO0MVcpD52UEMZKSv35nO/+8vUnLGoFC1E3BIVjHEIdr6IFFM7ty2E7FWsaZGsah3XrFINKT3dPd2Atz+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/react-dom-shim": "10.6.1", + "react-docgen": "^8.0.2", + "react-docgen-typescript": "^2.2.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.6.1", + "typescript": ">= 4.9.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@storybook/react-dom-shim": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-10.6.1.tgz", + "integrity": "sha512-OkPFycR6kVE+9ZsgQWRqnUfZm/sLY6dxsNAn3lsvV7DSAQWH9QnHLkqP9E/N/Hg6cKPDtGzDZ8vPNDz2FuaDuQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.6.1" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@storybook/react-vite": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-10.6.1.tgz", + "integrity": "sha512-oyUZxuW2lIJN8BRbYyMyZbfjeYqtF6HwmNfqa1SAF525zRnMewYp66wm/5d8FAgwqejOf6N2+wR3hX866lSwMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", + "@rollup/pluginutils": "^5.0.2", + "@storybook/builder-vite": "10.6.1", + "@storybook/react": "10.6.1", + "empathic": "^2.0.0", + "magic-string": "^1.1.0", + "react-docgen": "^8.0.2", + "resolve": "^1.22.8", + "tsconfig-paths": "^4.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "storybook": "^10.6.1", + "typescript": ">= 4.9.x", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/doctrine": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", + "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz", + "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/resolve": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.6.tgz", + "integrity": "sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webcontainer/env": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@webcontainer/env/-/env-1.1.1.tgz", + "integrity": "sha512-6aN99yL695Hi9SuIk1oC88l9o0gmxL1nGWWQ/kNy81HigJ0FoaoTXpytCj6ItzgyCEwA9kF1wixsTuv5cjsgng==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-types": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.3.tgz", + "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", + "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.448", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.448.tgz", + "integrity": "sha512-c3DEaf2iJbLz7XvmYGZQrzaxy6Azt2/NcWAV1r3GkibEyRNkVer6t3JevvwAQ6gW2JBGGY2N/MGMS/QV+77Ldw==", + "dev": true, + "license": "ISC" + }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oxc-parser": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.127.0.tgz", + "integrity": "sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.127.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.127.0", + "@oxc-parser/binding-android-arm64": "0.127.0", + "@oxc-parser/binding-darwin-arm64": "0.127.0", + "@oxc-parser/binding-darwin-x64": "0.127.0", + "@oxc-parser/binding-freebsd-x64": "0.127.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.127.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.127.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.127.0", + "@oxc-parser/binding-linux-arm64-musl": "0.127.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.127.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.127.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-gnu": "0.127.0", + "@oxc-parser/binding-linux-x64-musl": "0.127.0", + "@oxc-parser/binding-openharmony-arm64": "0.127.0", + "@oxc-parser/binding-wasm32-wasi": "0.127.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.127.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.127.0", + "@oxc-parser/binding-win32-x64-msvc": "0.127.0" + } + }, + "node_modules/oxc-resolver": { + "version": "11.21.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.2.tgz", + "integrity": "sha512-w5tLwYN3Zo24w5EeWJjJWZOwhYqTtC8PS2B1tIt7BZUuqTIcU07sQValbDw+rq7+AuAGzOHklgK+ifsy4lpXfw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.21.2", + "@oxc-resolver/binding-android-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-arm64": "11.21.2", + "@oxc-resolver/binding-darwin-x64": "11.21.2", + "@oxc-resolver/binding-freebsd-x64": "11.21.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.21.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.21.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.21.2", + "@oxc-resolver/binding-linux-x64-musl": "11.21.2", + "@oxc-resolver/binding-openharmony-arm64": "11.21.2", + "@oxc-resolver/binding-wasm32-wasi": "11.21.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.21.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.21.2" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-docgen": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-8.0.4.tgz", + "integrity": "sha512-gIYjtVTU4NCWZnb/LXVSWfClZXnjzX3+xxi69Tkfu4YsmYuqiOk8giLkKt/WwxWqh+U66CTh8Kqbha1uPv8/qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.7", + "@types/doctrine": "^0.0.9", + "@types/resolve": "^1.20.2", + "doctrine": "^3.0.0", + "resolve": "^1.22.1", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": "^20.9.0 || >=22" + } + }, + "node_modules/react-docgen-typescript": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", + "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 4.3.x" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/recast": { + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redent/node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/storybook": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/storybook/-/storybook-10.6.1.tgz", + "integrity": "sha512-NeEmukMWPA5OqHizUkQtxCIhGt7MUZLPY4FFgpC0LNiipjrac/oKpH0TxI+9Zc+vI5+IoN+NiskOmBP7gtYfmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@storybook/global": "^5.0.0", + "@storybook/icons": "^2.0.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/user-event": "^14.6.3", + "@vitest/expect": "3.2.4", + "@vitest/spy": "3.2.4", + "@webcontainer/env": "^1.1.1", + "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", + "jsonc-parser": "^3.3.1", + "open": "^10.2.0", + "oxc-parser": "^0.127.0", + "oxc-resolver": "11.21.2", + "recast": "^0.23.5", + "semver": "^7.7.3", + "use-sync-external-store": "^1.5.0", + "ws": "^8.21.1" + }, + "bin": { + "storybook": "dist/bin/dispatcher.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/storybook" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "prettier": "^2 || ^3", + "vite-plus": "^0.1.15 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "prettier": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/storybook/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz", + "integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==", + "license": "MIT" + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-dedent": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.3.0.tgz", + "integrity": "sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.10" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 000000000..a8573c80e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,53 @@ +{ + "name": "@parseable/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit && vite build --base=/next/", + "package": "node scripts/package.mjs", + "preview": "vite preview --host 127.0.0.1", + "test": "vitest run src", + "test:ui": "playwright test", + "storybook": "storybook dev -p 6006 --no-open --disable-telemetry", + "build-storybook": "storybook build --disable-telemetry", + "check": "npm run format:check && npm run build && npm test && npm run test:ui && npm run test:stories", + "format": "prettier --write src e2e e2e-live story-tests .storybook *.ts *.json docs README.md", + "format:check": "prettier --check src e2e e2e-live story-tests .storybook *.ts *.json docs README.md", + "test:stories": "npm run build-storybook && playwright test -c playwright.storybook.config.ts", + "test:live": "playwright test -c playwright.live.config.ts" + }, + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.6", + "@codemirror/view": "^6.43.13", + "@lezer/highlight": "^1.2.5", + "@radix-ui/react-dialog": "^1.2.0", + "@radix-ui/react-tabs": "^1.1.22", + "codemirror": "^6.0.2", + "lucide-react": "^1.52.0", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "react-router-dom": "^7.18.4" + }, + "devDependencies": { + "@axe-core/playwright": "^4.13.0", + "@playwright/test": "^1.63.0", + "@storybook/addon-docs": "^10.6.1", + "@storybook/react-vite": "^10.6.1", + "@types/node": "^26.6.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.1.2", + "prettier": "^3.9.9", + "storybook": "^10.6.1", + "typescript": "^7.0.2", + "vite": "^8.3.3", + "vitest": "^5.0.3" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 000000000..4f009d956 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: [['list'], ['html', { open: 'never' }]], + use: { + baseURL: 'http://127.0.0.1:5173', + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}, + }, + webServer: { + command: 'VITE_ENABLE_DEMO=true npm run dev', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/playwright.live.config.ts b/frontend/playwright.live.config.ts new file mode 100644 index 000000000..4185ecaae --- /dev/null +++ b/frontend/playwright.live.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test'; + +// Opt in explicitly. This suite writes a sample dataset to the supplied server. +export default defineConfig({ + testDir: './e2e-live', + testMatch: '**/*.spec.ts', + workers: 1, + fullyParallel: false, + timeout: 45_000, + outputDir: 'test-results/live', + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report/live' }]], + use: { + ...devices['Desktop Chrome'], + baseURL: process.env.PARSEABLE_LIVE_URL || 'http://127.0.0.1:8270', + trace: 'retain-on-failure', + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}, + }, +}); diff --git a/frontend/playwright.storybook.config.ts b/frontend/playwright.storybook.config.ts new file mode 100644 index 000000000..953ef9402 --- /dev/null +++ b/frontend/playwright.storybook.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './story-tests', + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + outputDir: 'test-results/storybook', + reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report/storybook' }]], + use: { + baseURL: 'http://127.0.0.1:6006', + trace: 'retain-on-failure', + ...devices['Desktop Chrome'], + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH + ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } + : {}, + }, + webServer: { + command: 'vite preview --outDir storybook-static --port 6006 --strictPort --host 127.0.0.1', + url: 'http://127.0.0.1:6006', + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/frontend/scripts/package.mjs b/frontend/scripts/package.mjs new file mode 100644 index 000000000..861ad0172 --- /dev/null +++ b/frontend/scripts/package.mjs @@ -0,0 +1,92 @@ +import { createHash } from 'node:crypto'; +import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crc32, deflateRawSync } from 'node:zlib'; + +const frontend = fileURLToPath(new URL('../', import.meta.url)); +const dist = join(frontend, 'dist'); +if (!(await lstat(join(dist, 'index.html'))).isFile()) { + throw new Error('Run npm run build first: dist/index.html must be a regular file.'); +} + +// Write a standard ZIP with Node alone, so packaging also works on Windows. +// Sorted paths, fixed 1980-01-01 timestamps, and fixed Unix permissions make +// identical dist contents reproducible with the same Node/zlib version. +const entries = []; +async function collect(directory, archivePath) { + if (!(await lstat(directory)).isDirectory()) { + throw new Error(`Expected a regular directory: ${directory}`); + } + entries.push({ name: archivePath, directory: true, data: Buffer.alloc(0) }); + const children = await readdir(directory, { withFileTypes: true }); + children.sort((a, b) => Buffer.compare(Buffer.from(a.name), Buffer.from(b.name))); + for (const child of children) { + const path = join(directory, child.name); + const name = archivePath + child.name; + if (child.isDirectory()) { + await collect(path, `${name}/`); + } else if (child.isFile()) { + entries.push({ name, directory: false, data: await readFile(path) }); + } else { + throw new Error(`Unsupported asset (symlinks are not packaged): ${path}`); + } + } +} +await collect(dist, 'dist/'); + +const files = []; +const directory = []; +let offset = 0; +for (const entry of entries) { + const name = Buffer.from(entry.name, 'utf8'); + const compressed = entry.directory ? entry.data : deflateRawSync(entry.data, { level: 9 }); + const method = entry.directory ? 0 : 8; + if ( + name.length > 0xffff || + Math.max(entry.data.length, compressed.length, offset) >= 0xffffffff + ) { + throw new Error('Frontend archive exceeds standard ZIP limits (ZIP64 is not supported).'); + } + + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // Local file header. + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x0800, 6); // UTF-8 names. + local.writeUInt16LE(method, 8); + local.writeUInt16LE(33, 12); // DOS date: 1980-01-01; time remains midnight. + local.writeUInt32LE(crc32(entry.data), 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(entry.data.length, 22); + local.writeUInt16LE(name.length, 26); + files.push(local, name, compressed); + + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // Central directory header. + central.writeUInt16LE(0x0314, 4); // Created on Unix, ZIP 2.0. + local.copy(central, 6, 4, 30); + const mode = entry.directory ? 0o40755 : 0o100644; + central.writeUInt32LE(((mode << 16) | (entry.directory ? 0x10 : 0)) >>> 0, 38); + central.writeUInt32LE(offset, 42); + directory.push(central, name); + offset += local.length + name.length + compressed.length; +} + +const directorySize = directory.reduce((size, chunk) => size + chunk.length, 0); +if (entries.length >= 0xffff || offset + directorySize >= 0xffffffff) { + throw new Error('Frontend archive exceeds standard ZIP limits (ZIP64 is not supported).'); +} +const end = Buffer.alloc(22); +end.writeUInt32LE(0x06054b50, 0); // End of central directory. +end.writeUInt16LE(entries.length, 8); +end.writeUInt16LE(entries.length, 10); +end.writeUInt32LE(directorySize, 12); +end.writeUInt32LE(offset, 16); + +const zip = Buffer.concat([...files, ...directory, end]); +const sha1 = createHash('sha1').update(zip).digest('hex'); +const checksum = `${sha1} build.zip\n`; +await writeFile(join(frontend, 'build.zip'), zip); +await writeFile(join(frontend, 'build.zip.sha1'), checksum); +console.log(`Created build.zip (${zip.length} bytes, ${entries.length} entries)`); +process.stdout.write(checksum); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx new file mode 100644 index 000000000..f03e981de --- /dev/null +++ b/frontend/src/app/App.tsx @@ -0,0 +1,170 @@ +import { Suspense, lazy, useEffect, useState } from 'react'; +import { Link, Navigate, Route, Routes, useLocation } from 'react-router-dom'; +import { ChevronRight, ExternalLink, Moon, Sun, LogOut } from 'lucide-react'; +import { Badge, Button, EmptyState, Spinner } from '../components/ui'; +import { Sidebar } from './Sidebar'; +import { ConnectionDialog } from './ConnectionDialog'; +import { useApp } from './AppProvider'; +import { LoginPage } from './LoginPage'; +import { safeReturnPath } from '../lib/auth'; +import { classicUiAvailable, classicUiPath } from '../lib/classicUi'; +import '../styles/auth-shell.css'; +const LogsPage = lazy(() => + import('../features/logs/LogsPage').then((m) => ({ default: m.LogsPage })), +); +const SqlPage = lazy(() => import('../features/sql/SqlPage').then((m) => ({ default: m.SqlPage }))); +const DatasetsPage = lazy(() => + import('../features/datasets/DatasetsPage').then((m) => ({ default: m.DatasetsPage })), +); +const DashboardsPage = lazy(() => + import('../features/dashboards/DashboardsPage').then((m) => ({ default: m.DashboardsPage })), +); +const LibraryPage = lazy(() => + import('../features/library/LibraryPage').then((m) => ({ default: m.LibraryPage })), +); +import { OverviewPage } from './OverviewPage'; +export function App() { + const { mode, session, identity, needsLogin, client } = useApp(); + const location = useLocation(); + const [collapsed, setCollapsed] = useState(false); + const [connect, setConnect] = useState(false); + const [dark, setDark] = useState(() => localStorage.getItem('parseable-theme') === 'dark'); + useEffect(() => { + document.documentElement.dataset.theme = dark ? 'dark' : 'light'; + localStorage.setItem('parseable-theme', dark ? 'dark' : 'light'); + }, [dark]); + const section = + ( + { + logs: 'Logs', + 'sql-editor': 'SQL editor', + datasets: 'Datasets', + dashboards: 'Dashboards', + components: 'Component library', + } as Record + )[location.pathname.split('/')[1]] || 'Overview'; + if (location.pathname === '/login' || location.pathname === '/oidc-not-configured') { + return ; + } + if (mode === 'live' && needsLogin && location.pathname !== '/components') { + const next = safeReturnPath(`${location.pathname}${location.search}${location.hash}`); + return ; + } + return ( +
+ + Skip to content + + setCollapsed(!collapsed)} + onConnect={() => setConnect(true)} + /> +
+
+
+ + + + Community + + {section} +
+
+
+ {classicUiAvailable && ( + + Compare in classic UI + )} + + {mode === 'demo' ? 'Demo data' : 'Live server'} + + +
+ + {identity?.username || 'Server workspace'} +
+ {mode === 'live' && ( + + )} +
+
+ {mode === 'demo' && ( +
+ Demo workspace · sample data, local changes only + +
+ )} +
+ + + Loading view… +
+ } + > + + setConnect(true)} />} /> + } /> + } /> + } /> + } /> + } /> + } /> + + Back to overview} + /> +
+ } + /> + + + + + + + ); +} diff --git a/frontend/src/app/AppProvider.tsx b/frontend/src/app/AppProvider.tsx new file mode 100644 index 000000000..ef642ae95 --- /dev/null +++ b/frontend/src/app/AppProvider.tsx @@ -0,0 +1,73 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { ApiError, createClient } from '../lib/client'; +import { clearSessionCookies } from '../lib/auth'; +import { demoEnabled } from '../lib/config'; +import type { SessionIdentity } from '../lib/types'; +export type Mode = 'demo' | 'live'; +const Context = createContext | null>(null); +function useAppState() { + const [mode, setModeState] = useState(() => { + if (demoEnabled && sessionStorage.getItem('parseable-mode') === 'demo') return 'demo'; + sessionStorage.removeItem('parseable-mode'); + return 'live'; + }); + const [session, setSession] = useState(0); + const [needsLogin, setNeedsLogin] = useState(false); + const [identity, setIdentity] = useState(); + const requireLogin = useCallback(() => { + clearSessionCookies(); + setIdentity(undefined); + setNeedsLogin(true); + }, []); + const client = useMemo( + () => createClient({ mode, onUnauthorized: requireLogin }), + [mode, session, requireLogin], + ); + useEffect(() => { + const controller = new AbortController(); + client + .identity(controller.signal) + .then(async (value) => { + // Pages with only local content still need expiry recovery. A profile + // response proves a server session; cookie display hints do not. + if (mode === 'live' && value?.source !== 'server') { + try { + await client.listDatasets(controller.signal); + } catch (error) { + // The client reports 401 centrally. Other errors belong to the page. + if (error instanceof ApiError && error.status === 401) return; + } + } + if (!controller.signal.aborted) setIdentity(value); + }) + .catch(() => { + /* Identity is optional; data request errors remain visible. */ + }); + return () => controller.abort(); + }, [client, mode]); + const setMode = (value: Mode) => { + const allowedMode = value === 'demo' && demoEnabled ? 'demo' : 'live'; + sessionStorage.setItem('parseable-mode', allowedMode); + setModeState(allowedMode); + setIdentity(undefined); + setNeedsLogin(false); + setSession((s) => s + 1); + }; + return { mode, setMode, client, session, identity, needsLogin, demoEnabled }; +} +export function AppProvider({ children }: { children: ReactNode }) { + return {children}; +} +export function useApp() { + const ctx = useContext(Context); + if (!ctx) throw new Error('Missing AppProvider'); + return ctx; +} diff --git a/frontend/src/app/ConnectionDialog.tsx b/frontend/src/app/ConnectionDialog.tsx new file mode 100644 index 000000000..311d472f1 --- /dev/null +++ b/frontend/src/app/ConnectionDialog.tsx @@ -0,0 +1,51 @@ +import { useLocation } from 'react-router-dom'; +import { Button, Dialog } from '../components/ui'; +import { useApp } from './AppProvider'; +import { LoginForm } from './LoginForm'; +export function ConnectionDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; +}) { + const { setMode, mode, client, demoEnabled } = useApp(); + const location = useLocation(); + return ( + + {open && ( + { + setMode('live'); + onOpenChange(false); + }} + /> + )} +
+ {demoEnabled && ( + + )} + {mode === 'live' && ( + + )} +
+
+ ); +} diff --git a/frontend/src/app/LoginForm.tsx b/frontend/src/app/LoginForm.tsx new file mode 100644 index 000000000..c848f22f7 --- /dev/null +++ b/frontend/src/app/LoginForm.tsx @@ -0,0 +1,103 @@ +import { useState, type FormEvent } from 'react'; +import { Eye, EyeOff, KeyRound } from 'lucide-react'; +import { Button, Dialog, Input } from '../components/ui'; +import { createClient } from '../lib/client'; +import { startSso } from '../lib/auth'; + +export function LoginForm({ + returnPath, + onSuccess, + submitLabel = 'Login', +}: { + returnPath: string; + onSuccess: () => void; + submitLabel?: string; +}) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [visible, setVisible] = useState(false); + const [forgot, setForgot] = useState(false); + const [error, setError] = useState(''); + const [pending, setPending] = useState(false); + async function connect(event: FormEvent) { + event.preventDefault(); + setError(''); + setPending(true); + try { + await createClient({ mode: 'live' }).login(username, password, returnPath); + setPassword(''); + onSuccess(); + } catch (error) { + setError(error instanceof Error ? error.message : 'Unable to sign in'); + } finally { + setPending(false); + } + } + return ( + <> +
+ setUsername(e.target.value)} + required + disabled={pending} + /> +
+ setPassword(e.target.value)} + required + disabled={pending} + /> + +
+ + {error && ( +

+ {error} +

+ )} + +
+ OR WITH OAUTH +
+ +
+ + + + + ); +} diff --git a/frontend/src/app/LoginPage.tsx b/frontend/src/app/LoginPage.tsx new file mode 100644 index 000000000..7353222b8 --- /dev/null +++ b/frontend/src/app/LoginPage.tsx @@ -0,0 +1,105 @@ +import { Brain, Database, Globe2, Telescope, Zap } from 'lucide-react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Button } from '../components/ui'; +import { intendedReturnPath } from '../lib/auth'; +import { appPath } from '../lib/config'; +import { useApp } from './AppProvider'; +import { LoginForm } from './LoginForm'; + +export function LoginPage({ oidcUnavailable = false }: { oidcUnavailable?: boolean }) { + const { setMode, demoEnabled } = useApp(); + const location = useLocation(); + const navigate = useNavigate(); + const next = intendedReturnPath(location.search); + return ( +
+
+
+ + + parseable + +
+

+ Observability +
+ Simplified. +

+

AI Native observability datalake.

+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+

Self-hosted workspace · Your infrastructure. Your data.

+
+
+

+ {oidcUnavailable ? 'Single sign-on is not configured' : 'Log in to your account'} +

+ {oidcUnavailable && ( +

+ This server does not have an OAuth provider configured. Sign in with your native + account or contact your administrator. +

+ )} + { + setMode('live'); + navigate(next, { replace: true }); + }} + /> + {demoEnabled ? ( + + ) : ( +

Need help? Contact your administrator

+ )} + {oidcUnavailable && ( + + Back to native sign in + + )} +
+
+
+ ); +} diff --git a/frontend/src/app/OverviewPage.tsx b/frontend/src/app/OverviewPage.tsx new file mode 100644 index 000000000..074e3caa9 --- /dev/null +++ b/frontend/src/app/OverviewPage.tsx @@ -0,0 +1,90 @@ +import { useCallback } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowUpRight, Database, Logs, Terminal, Layers, Plug } from 'lucide-react'; +import { Button, Card, Badge } from '../components/ui'; +import { PageHeader } from '../components/explorer/PageHeader'; +import { QueryState } from '../components/explorer/QueryState'; +import { useAsync } from '../hooks/useAsync'; +import { useApp } from './AppProvider'; +export function OverviewPage({ onConnect }: { onConnect: () => void }) { + const { client, mode } = useApp(); + const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); + return ( +
+ + + Connection + + } + /> + +
+ PARSEABLE WORKSPACE +

From events to understanding.

+

+ Search your logs, ask questions with SQL, and build a shared language for your data. +

+ + Explore logs + +
+ +
+
+ + + Available datasets + {datasets.loading ? '…' : datasets.error ? '—' : datasets.data?.length} + + + + Workspace + {mode === 'demo' ? 'Demo' : 'Self-hosted'} + + + + Query language + SQL + +
+ +
+

Start exploring

+ + View all datasets + +
+
+ {datasets.data?.map((d) => ( + +
+ +
+
+ {d.name} +

Explore events and inspect fields

+
+ + + ))} +
+
+ This editable frontend starts with logs, SQL, datasets, local dashboards, and reusable + components. Metrics, traces, alerts, and other advanced views will follow in later + iterations. +
+
+ ); +} diff --git a/frontend/src/app/Sidebar.tsx b/frontend/src/app/Sidebar.tsx new file mode 100644 index 000000000..cffdde9ff --- /dev/null +++ b/frontend/src/app/Sidebar.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { NavLink } from 'react-router-dom'; +import { + Activity, + Database, + ExternalLink, + FileCode2, + LayoutDashboard, + Library, + Logs, + Plug, + PanelLeftClose, + PanelLeftOpen, + Search, +} from 'lucide-react'; +import { Button, Badge } from '../components/ui'; +import { useApp } from './AppProvider'; +import { classicOnlyPages, classicUiAvailable } from '../lib/classicUi'; +const groups = [ + { label: '', links: [{ to: '/', label: 'Overview', id: 'home', icon: Activity }] }, + { + label: 'Analyze', + links: [ + { to: '/dashboards', label: 'Dashboards', id: 'dashboards', icon: LayoutDashboard }, + { to: '/sql-editor', label: 'SQL editor', id: 'sql-editor', icon: FileCode2 }, + ], + }, + { label: 'Observe', links: [{ to: '/logs', label: 'Logs', id: 'logs', icon: Logs }] }, + { + label: 'Data', + links: [{ to: '/datasets', label: 'Datasets', id: 'datasets', icon: Database }], + }, + { + label: 'Develop', + links: [{ to: '/components', label: 'Component library', id: 'components', icon: Library }], + }, +]; +export function Sidebar({ + collapsed, + onToggle, + onConnect, +}: { + collapsed: boolean; + onToggle: () => void; + onConnect: () => void; +}) { + const { mode, demoEnabled } = useApp(); + const [search, setSearch] = useState(''); + return ( + + ); +} diff --git a/frontend/src/components/explorer/ColumnPicker.tsx b/frontend/src/components/explorer/ColumnPicker.tsx new file mode 100644 index 000000000..4acacb908 --- /dev/null +++ b/frontend/src/components/explorer/ColumnPicker.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Columns3 } from 'lucide-react'; +import { Button, Dialog, Input } from '../ui'; +export function ColumnPicker({ + fields, + selected, + onChange, +}: { + fields: string[]; + selected: string[]; + onChange: (fields: string[]) => void; +}) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + return ( + <> + + + setSearch(e.target.value)} + /> +
+ {fields + .filter((f) => f.toLowerCase().includes(search.toLowerCase())) + .map((field) => ( + + ))} +
+ +
+ + ); +} diff --git a/frontend/src/components/explorer/DataTable.tsx b/frontend/src/components/explorer/DataTable.tsx new file mode 100644 index 000000000..fe9f573c3 --- /dev/null +++ b/frontend/src/components/explorer/DataTable.tsx @@ -0,0 +1,224 @@ +import { useState } from 'react'; +import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react'; +import { Badge, Button, EmptyState } from '../ui'; +import type { LogRecord } from '../../lib/types'; +export function formatValue(value: unknown): string { + if (value == null) return '—'; + return typeof value === 'object' ? JSON.stringify(value) : String(value); +} +export function exportRecords(rows: LogRecord[], format: 'json' | 'csv') { + const fields = [...new Set(rows.flatMap(Object.keys))]; + // Prefix spreadsheet formulas in CSV so exported log text remains data. + const csvCell = (value: unknown) => { + let text = value == null ? '' : formatValue(value); + if (/^[=+@\-\t\r]/.test(text)) text = `'${text}`; + return `"${text.replaceAll('"', '""')}"`; + }; + const content = + format === 'json' + ? JSON.stringify(rows, null, 2) + : [ + fields.map(csvCell).join(','), + ...rows.map((row) => fields.map((f) => csvCell(row[f])).join(',')), + ].join('\r\n'); + const url = URL.createObjectURL( + new Blob([content], { + type: format === 'json' ? 'application/json' : 'text/csv;charset=utf-8', + }), + ); + const link = document.createElement('a'); + link.href = url; + link.download = `query-results.${format}`; + link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} +export function DataTable({ + rows, + columns, + onSelect, + caption = 'Query results', + wrap = false, + summary = false, +}: { + rows: LogRecord[]; + columns?: string[]; + onSelect?: (row: LogRecord) => void; + caption?: string; + wrap?: boolean; + summary?: boolean; +}) { + const [page, setPage] = useState(0); + const [sort, setSort] = useState<{ field: string; descending: boolean }>(); + const pageSize = 25; + const effectivePage = Math.min(page, Math.max(0, Math.ceil(rows.length / pageSize) - 1)); + const fields = columns || [...new Set(rows.flatMap(Object.keys))]; + const ordered = sort + ? [...rows].sort((a, b) => { + const left = a[sort.field], + right = b[sort.field]; + const result = + typeof left === 'number' && typeof right === 'number' + ? left - right + : formatValue(left).localeCompare(formatValue(right), undefined, { numeric: true }); + return sort.descending ? -result : result; + }) + : rows; + if (!rows.length) + return ( + + ); + return ( +
+
+ + + + + {onSelect && ( + + )} + {fields.map((field) => ( + + ))} + {summary && } + + + + {ordered + .slice(effectivePage * pageSize, (effectivePage + 1) * pageSize) + .map((row, index) => ( + + {onSelect && ( + + )} + {fields.map((field) => ( + + ))} + {summary && ( + + )} + + ))} + +
{caption}
+ Details + + + Data
+ + + {field === 'level' ? ( + + {formatValue(row[field])} + + ) : field === 'p_timestamp' ? ( + formatValue(row[field]).replace('T', ' ').replace('Z', '') + ) : ( + formatValue(row[field]) + )} + + {Object.entries(row) + .filter(([key]) => !fields.includes(key)) + .map(([key, value]) => ( + + {key}= + {formatValue(value)}{' '} + + ))} +
+
+
+ + {rows.length.toLocaleString()} rows · {effectivePage * pageSize + 1}– + {Math.min((effectivePage + 1) * pageSize, rows.length)} + +
+ + + Page {effectivePage + 1} of {Math.ceil(rows.length / pageSize)} + + +
+
+
+ ); +} diff --git a/frontend/src/components/explorer/LogHistogram.tsx b/frontend/src/components/explorer/LogHistogram.tsx new file mode 100644 index 000000000..54b392a6c --- /dev/null +++ b/frontend/src/components/explorer/LogHistogram.tsx @@ -0,0 +1,52 @@ +import { parseEventTimestamp } from './timestamp'; +import type { LogRecord } from '../../lib/types'; +export function LogHistogram({ + rows, + bounds, +}: { + rows: LogRecord[]; + bounds?: { startTime: string; endTime: string }; +}) { + const timestamps = rows + .map((row) => parseEventTimestamp(row.p_timestamp)) + .filter(Number.isFinite); + const min = bounds ? Date.parse(bounds.startTime) : Math.min(...timestamps); + const max = bounds ? Date.parse(bounds.endTime) : Math.max(...timestamps); + const bins = Array.from({ length: 48 }, () => 0); + timestamps.forEach((t) => { + bins[Math.max(0, Math.min(47, Math.floor(((t - min) / Math.max(1, max - min)) * 48)))]++; + }); + const peak = Math.max(...bins, 1); + return ( +
+
+

Logs over time

+ {rows.length} returned events +
+
+
+ {bins.map((count, i) => ( +
+
+
+ ))} +
+
+ + {timestamps.length ? new Date(min).toLocaleTimeString([], { timeZone: 'UTC' }) : 'Start'} + + Returned rows · UTC + + {timestamps.length ? new Date(max).toLocaleTimeString([], { timeZone: 'UTC' }) : 'End'} + +
+
+ ); +} diff --git a/frontend/src/components/explorer/PageHeader.tsx b/frontend/src/components/explorer/PageHeader.tsx new file mode 100644 index 000000000..25c60982d --- /dev/null +++ b/frontend/src/components/explorer/PageHeader.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +export function PageHeader({ + title, + description, + actions, +}: { + title: string; + description?: string; + actions?: ReactNode; +}) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/frontend/src/components/explorer/QueryState.tsx b/frontend/src/components/explorer/QueryState.tsx new file mode 100644 index 000000000..ada895a0a --- /dev/null +++ b/frontend/src/components/explorer/QueryState.tsx @@ -0,0 +1,69 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { Button, EmptyState, Spinner } from '../ui'; +import { useApp } from '../../app/AppProvider'; +import { ApiError } from '../../lib/client'; +export function QueryState({ + loading, + error, + retry, +}: { + loading: boolean; + error?: Error; + retry?: () => void; +}) { + const { mode, setMode, demoEnabled } = useApp(); + const navigate = useNavigate(); + const location = useLocation(); + if (loading) + return ( +
+ + Loading data… +
+ ); + if (!error) return null; + const unauthorized = error instanceof ApiError && error.status === 401; + const forbidden = error instanceof ApiError && error.status === 403; + return ( +
+ + {unauthorized ? ( + + ) : ( + retry && ( + + ) + )} + {mode === 'live' && demoEnabled && !unauthorized && !forbidden && ( + + )} +
+ } + /> + + ); +} diff --git a/frontend/src/components/explorer/RecordSheet.tsx b/frontend/src/components/explorer/RecordSheet.tsx new file mode 100644 index 000000000..b85aaacb8 --- /dev/null +++ b/frontend/src/components/explorer/RecordSheet.tsx @@ -0,0 +1,149 @@ +import { useState } from 'react'; +import { Copy, Filter, FilterX } from 'lucide-react'; +import { Button, Sheet, Input, Tabs } from '../ui'; +import { formatValue } from './DataTable'; +import type { LogRecord } from '../../lib/types'; +export function RecordSheet({ + row, + onClose, + onFilter, +}: { + row?: LogRecord; + onClose: () => void; + onFilter?: (field: string, value: string, operator?: '=' | '!=') => void; +}) { + const [search, setSearch] = useState(''); + const [view, setView] = useState('fields'); + // Each opening of the sheet is a new session, even for the same event. Status + // belongs to the session it was set in, so a copy that finishes after the + // sheet closed or reopened is never reported, nor clears the current status. + const [session, setSession] = useState<{ row?: LogRecord; id: object; status: string }>({ + row, + id: {}, + status: '', + }); + if (session.row !== row) { + setSession({ row, id: {}, status: '' }); + setSearch(''); + } + const report = (id: object, status: string) => + setSession((current) => (current.id === id ? { ...current, status } : current)); + return ( + { + if (!open) onClose(); + }} + title="Event details" + description="Inspect fields and values for this event." + > +
+ + {session.status} + + setSearch(e.target.value)} + /> +
+ {Object.entries(row || {}) + .filter(([key, value]) => + `${key} ${formatValue(value)}`.toLowerCase().includes(search.toLowerCase()), + ) + .map(([key, value]) => ( +
+
{key}
+
+ {formatValue(value)} +
+ {onFilter && typeof value === 'string' && ( + <> + + + + )} + +
+
+
+ ))} +
+ + ), + }, + { + value: 'json', + label: 'JSON', + content:
{JSON.stringify(row, null, 2)}
, + }, + ]} + /> +
+
+ ); +} diff --git a/frontend/src/components/explorer/TimeRangePicker.tsx b/frontend/src/components/explorer/TimeRangePicker.tsx new file mode 100644 index 000000000..e21a6f2c2 --- /dev/null +++ b/frontend/src/components/explorer/TimeRangePicker.tsx @@ -0,0 +1,110 @@ +import { useState } from 'react'; +import { Clock, CalendarDays } from 'lucide-react'; +import { Button, Dialog, Input, Select } from '../ui'; +import { timeBounds } from '../../lib/query'; +import type { TimeRange } from '../../lib/types'; +export function TimeRangePicker({ + value, + onChange, +}: { + value: TimeRange; + onChange: (value: TimeRange) => void; +}) { + const [open, setOpen] = useState(false); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [error, setError] = useState(''); + const begin = () => { + const bounds = timeBounds(value); + setFrom(bounds.startTime.slice(0, 19)); + setTo(bounds.endTime.slice(0, 19)); + setError(''); + setOpen(true); + }; + return ( +
+
+ ); +} diff --git a/frontend/src/components/explorer/timestamp.test.ts b/frontend/src/components/explorer/timestamp.test.ts new file mode 100644 index 000000000..13b6cb392 --- /dev/null +++ b/frontend/src/components/explorer/timestamp.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { parseEventTimestamp } from './timestamp'; +describe('Arrow event timestamps', () => { + it('interprets timezone-less timestamps as UTC in a non-UTC browser timezone', () => { + const previous = process.env.TZ; + process.env.TZ = 'America/Chicago'; + try { + expect(parseEventTimestamp('2026-10-07T14:00:44.985')).toBe( + Date.parse('2026-10-07T14:00:44.985Z'), + ); + expect(parseEventTimestamp('2026-10-07 14:00:44.985')).toBe( + Date.parse('2026-10-07T14:00:44.985Z'), + ); + expect(parseEventTimestamp('2026-10-07T09:00:44.985-05:00')).toBe( + Date.parse('2026-10-07T14:00:44.985Z'), + ); + expect(parseEventTimestamp(null)).toBeNaN(); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } + }); +}); diff --git a/frontend/src/components/explorer/timestamp.ts b/frontend/src/components/explorer/timestamp.ts new file mode 100644 index 000000000..7eaf69012 --- /dev/null +++ b/frontend/src/components/explorer/timestamp.ts @@ -0,0 +1,10 @@ +/** Arrow timestamps without an offset represent UTC, not the browser's timezone. */ +export function parseEventTimestamp(value: unknown): number { + if (typeof value !== 'string') return NaN; + const text = value.trim(); + return Date.parse( + /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(text) + ? `${text.replace(' ', 'T')}Z` + : text, + ); +} diff --git a/frontend/src/components/ui.stories.tsx b/frontend/src/components/ui.stories.tsx new file mode 100644 index 000000000..d8d98f7cd --- /dev/null +++ b/frontend/src/components/ui.stories.tsx @@ -0,0 +1,152 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Plus, RefreshCw } from 'lucide-react'; +import { Badge, Button, Card, Dialog, EmptyState, Input, Select, Sheet, Spinner, Tabs } from './ui'; + +const meta = { + title: 'Prism/Component library', + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +export const Buttons: Story = { + render: () => ( +
+ + + + + + + +
+ ), +}; + +export const Inputs: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +export const Badges: Story = { + render: () => ( +
+ Logs + Healthy + Warning + Error +
+ ), +}; + +function DialogExample() { + const [open, setOpen] = useState(false); + return ( + <> + + + +
+ + +
+
+ + ); +} +export const Modal: Story = { render: () => }; + +function SheetExample() { + const [open, setOpen] = useState(false); + return ( + <> + + +
+          {JSON.stringify(
+            { level: 'info', message: 'Request completed', service: 'gateway' },
+            null,
+            2,
+          )}
+        
+
+ + ); +} +export const DetailSheet: Story = { render: () => }; + +export const Empty: Story = { + render: () => ( + + Clear filters} + /> + + ), +}; + +function TabsExample() { + const [value, setValue] = useState('table'); + return ( + + ); +} +export const TabViews: Story = { render: () => }; +export const Loading: Story = { render: () => }; diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 000000000..2b7b4aede --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,13 @@ +import { forwardRef, type HTMLAttributes } from 'react'; +import { cx } from './utils'; + +export interface BadgeProps extends HTMLAttributes { + tone?: 'neutral' | 'success' | 'warning' | 'danger'; +} + +export const Badge = forwardRef(function Badge( + { tone = 'neutral', className, ...props }, + ref, +) { + return ; +}); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 000000000..419ef6af7 --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,23 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react'; +import { cx } from './utils'; + +export interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'icon'; +} + +export const Button = forwardRef(function Button( + { variant = 'secondary', size = 'md', className, type = 'button', ...props }, + ref, +) { + return ( + + + +
{children}
+ + + + ); +} + +export function Dialog(props: DialogProps) { + return ; +} diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 000000000..1be9f1fa4 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,22 @@ +import { type ReactNode } from 'react'; +import { Search } from 'lucide-react'; + +export interface EmptyStateProps { + title: string; + description?: string; + action?: ReactNode; + icon?: ReactNode; +} + +export function EmptyState({ title, description, action, icon }: EmptyStateProps) { + return ( +
+ +

{title}

+ {description &&

{description}

} + {action &&
{action}
} +
+ ); +} diff --git a/frontend/src/components/ui/Field.tsx b/frontend/src/components/ui/Field.tsx new file mode 100644 index 000000000..075aea4d2 --- /dev/null +++ b/frontend/src/components/ui/Field.tsx @@ -0,0 +1,52 @@ +import { type ReactNode, useId } from 'react'; +import { cx } from './utils'; + +export interface FieldOptions { + label?: string; + hint?: string; + error?: string; +} + +export function useField( + id: string | undefined, + describedBy: string | undefined, + { hint, error }: FieldOptions, +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const messageId = `${inputId}-message`; + return { + inputId, + messageId, + describedBy: cx(describedBy, (hint || error) && messageId) || undefined, + }; +} + +export function Field({ + children, + label, + hint, + error, + inputId, + messageId, +}: FieldOptions & { + children: ReactNode; + inputId: string; + messageId: string; +}) { + return ( +
+ {label && ( + + )} + {children} + {(hint || error) && ( +

+ {error || hint} +

+ )} +
+ ); +} diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 000000000..b3a4663f8 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type InputHTMLAttributes } from 'react'; +import { Field, useField, type FieldOptions } from './Field'; +import { cx } from './utils'; + +export interface InputProps extends InputHTMLAttributes, FieldOptions {} + +export const Input = forwardRef(function Input( + { + label, + hint, + error, + id, + className, + 'aria-describedby': describedBy, + 'aria-invalid': invalid, + ...props + }, + ref, +) { + const field = useField(id, describedBy, { hint, error }); + return ( + + + + ); +}); diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx new file mode 100644 index 000000000..e32067dcb --- /dev/null +++ b/frontend/src/components/ui/Select.tsx @@ -0,0 +1,37 @@ +import { forwardRef, type SelectHTMLAttributes } from 'react'; +import { ChevronDown } from 'lucide-react'; +import { Field, useField, type FieldOptions } from './Field'; +import { cx } from './utils'; + +export interface SelectProps extends SelectHTMLAttributes, FieldOptions {} + +export const Select = forwardRef(function Select( + { + label, + hint, + error, + id, + className, + 'aria-describedby': describedBy, + 'aria-invalid': invalid, + ...props + }, + ref, +) { + const field = useField(id, describedBy, { hint, error }); + return ( + +
+ setSearch(e.target.value)} + /> +
+ {dashboards.length} dashboards + +
+ Saved in this browser only. Each tile shows up to 100 returned events from the last hour. +
+ {error && ( +

+ {error} +

+ )} + {!dashboards.length ? ( + setOpen(true)}> + + Create your first dashboard + + } + /> + ) : ( +
+ {dashboards + .filter((d) => + `${d.title} ${d.description}`.toLowerCase().includes(search.toLowerCase()), + ) + .map((d) => ( + setDeleting(d)} /> + ))} +
+ )} + {!!dashboards.length && + !dashboards.some((d) => + `${d.title} ${d.description}`.toLowerCase().includes(search.toLowerCase()), + ) && } + +
+ setTitle(e.target.value)} + maxLength={80} + error={duplicate ? 'A dashboard with this name already exists.' : undefined} + required + /> + setDescription(e.target.value)} + /> + + + + +
+ { + if (!v) setDeleting(undefined); + }} + title="Delete dashboard?" + description={`Remove ${deleting?.title || 'this dashboard'} from this browser?`} + > + + + + ); +} diff --git a/frontend/src/features/dashboards/storage.test.ts b/frontend/src/features/dashboards/storage.test.ts new file mode 100644 index 000000000..adcd1bb84 --- /dev/null +++ b/frontend/src/features/dashboards/storage.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { uniqueTitles, type Dashboard } from './storage'; + +const dashboard = (id: string, title: string): Dashboard => ({ + id, + title, + description: '', + dataset: 'logs', +}); +describe('uniqueTitles', () => { + it('leaves distinct titles untouched', () => { + const list = [dashboard('a', 'API errors'), dashboard('b', 'Latency')]; + expect(uniqueTitles(list)).toEqual(list); + }); + it('renames later duplicates, ignoring case and whitespace', () => { + const titles = uniqueTitles([ + dashboard('a', 'API errors'), + dashboard('b', ' api ERRORS '), + dashboard('c', 'API errors'), + ]).map((d) => d.title); + expect(titles).toEqual(['API errors', 'api ERRORS (2)', 'API errors (3)']); + }); + it('skips suffixes that are already taken', () => { + const titles = uniqueTitles([ + dashboard('a', 'API errors'), + dashboard('b', 'API errors'), + dashboard('c', 'API errors (2)'), + ]).map((d) => d.title); + expect(titles).toEqual(['API errors', 'API errors (3)', 'API errors (2)']); + }); +}); diff --git a/frontend/src/features/dashboards/storage.ts b/frontend/src/features/dashboards/storage.ts new file mode 100644 index 000000000..2043ea9d5 --- /dev/null +++ b/frontend/src/features/dashboards/storage.ts @@ -0,0 +1,43 @@ +export type Dashboard = { id: string; title: string; description: string; dataset: string }; +/** Normalises a title the way accessible names are compared: trimmed, collapsed, case-insensitive. */ +export function titleKey(title: string) { + return title.trim().replace(/\s+/g, ' ').toLowerCase(); +} +/** Renames later duplicates to "Title (2)", "Title (3)", … so every tile gets a distinct label. */ +export function uniqueTitles(dashboards: Dashboard[]): Dashboard[] { + const taken = new Set(dashboards.map((d) => titleKey(d.title))); + const seen = new Set(); + return dashboards.map((d) => { + const key = titleKey(d.title); + if (!seen.has(key)) { + seen.add(key); + return d; + } + const base = d.title.trim().replace(/\s+/g, ' '); + let n = 2; + while (taken.has(titleKey(`${base} (${n})`))) n++; + const title = `${base} (${n})`; + taken.add(titleKey(title)); + seen.add(titleKey(title)); + return { ...d, title }; + }); +} +export function loadDashboards(key: string): Dashboard[] { + try { + const value: unknown = JSON.parse(localStorage.getItem(key) || '[]'); + return Array.isArray(value) + ? uniqueTitles( + value.filter( + (v): v is Dashboard => + v && + typeof v.id === 'string' && + typeof v.title === 'string' && + typeof v.description === 'string' && + typeof v.dataset === 'string', + ), + ) + : []; + } catch { + return []; + } +} diff --git a/frontend/src/features/datasets/DatasetsPage.tsx b/frontend/src/features/datasets/DatasetsPage.tsx new file mode 100644 index 000000000..03d59400b --- /dev/null +++ b/frontend/src/features/datasets/DatasetsPage.tsx @@ -0,0 +1,132 @@ +import { useCallback, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Database, ArrowUpRight, Search, RefreshCw } from 'lucide-react'; +import { Badge, Button, Card, Input, Sheet, EmptyState } from '../../components/ui'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { QueryState } from '../../components/explorer/QueryState'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; +function DatasetSchema({ dataset }: { dataset: string }) { + const { client } = useApp(); + const schema = useAsync( + useCallback((signal) => client.schema(dataset, signal), [client, dataset]), + ); + return ( + <> + +
+ {schema.data?.map((field) => ( +
+ {field} + field +
+ ))} +
+ + Explore dataset + + + ); +} +export function DatasetsPage() { + const { client } = useApp(); + const data = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState(); + const datasets = + data.data?.filter((d) => d.name.toLowerCase().includes(search.toLowerCase())) || []; + return ( +
+ + + Refresh + + } + /> +
+
+ + setSearch(e.target.value)} + /> +
+ {datasets.length} datasets +
+ + +
+ + + + + + + + + + + {datasets.map((d) => ( + + + + + + ))} + +
Datasets
NameTypeActions
+ + + {d.name} + + + {d.type} + +
+ + + Explore + +
+
+
+
+ {!data.loading && !data.error && !datasets.length && ( + + )} + { + if (!v) setSelected(undefined); + }} + title={selected || 'Dataset'} + description="Fields available in this dataset." + > + {selected && } + +
+ ); +} diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx new file mode 100644 index 000000000..f4be1823d --- /dev/null +++ b/frontend/src/features/library/LibraryPage.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { Plus } from 'lucide-react'; +import { + Badge, + Button, + Card, + Dialog, + EmptyState, + Input, + Select, + Sheet, + Tabs, +} from '../../components/ui'; +import { PageHeader } from '../../components/explorer/PageHeader'; +export function LibraryPage() { + const [dialog, setDialog] = useState(false); + const [sheet, setSheet] = useState(false); + const [tab, setTab] = useState('overview'); + return ( +
+ +
+ +

Actions

+

A consistent hierarchy for each next step.

+
+ + + + + +
+
+ +

Status

+

Status is communicated with text as well as color.

+
+ Neutral + Healthy + Warning + Error +
+
+ +

Form controls

+
+ + + +
+
+ +

Overlays

+

Focus stays inside the overlay and returns when it closes.

+
+ + +
+ + +
+ +
+
+ +

Compose fields, actions, and related data here.

+
+
+ +

Tabs

+ Use the arrow keys to switch tabs.

, + }, + { + value: 'details', + label: 'Details', + content:

Each panel has a clear purpose.

, + }, + ]} + /> +
+ +

Empty state

+ +
+
+
+ ); +} diff --git a/frontend/src/features/logs/FieldSidebar.tsx b/frontend/src/features/logs/FieldSidebar.tsx new file mode 100644 index 000000000..94c0ed7f2 --- /dev/null +++ b/frontend/src/features/logs/FieldSidebar.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Hash } from 'lucide-react'; +import { Input, Button } from '../../components/ui'; +export function FieldSidebar({ + fields, + selected, + onToggle, +}: { + fields: string[]; + selected: string[]; + onToggle: (field: string) => void; +}) { + const [search, setSearch] = useState(''); + return ( + + ); +} diff --git a/frontend/src/features/logs/FilterBar.tsx b/frontend/src/features/logs/FilterBar.tsx new file mode 100644 index 000000000..e22ff9ac6 --- /dev/null +++ b/frontend/src/features/logs/FilterBar.tsx @@ -0,0 +1,131 @@ +import { createId } from '../../lib/ids'; +import { useState, type FormEvent } from 'react'; +import { Plus, Search, X } from 'lucide-react'; +import { Button, Dialog, Input, Select } from '../../components/ui'; +export type LogFilter = { id: string; field: string; operator: '=' | '!='; value: string }; +export function FilterBar({ + search, + onSearch, + filters, + fields, + onAdd, + onRemove, + onUpdate, + onClear, +}: { + search: string; + onSearch: (v: string) => void; + filters: LogFilter[]; + fields: string[]; + onAdd: (v: LogFilter) => void; + onRemove: (id: string) => void; + onUpdate?: (filter: LogFilter) => void; + onClear?: () => void; +}) { + const [open, setOpen] = useState(false); + const [editing, setEditing] = useState(); + const [field, setField] = useState(''); + const [operator, setOperator] = useState<'=' | '!='>('='); + const [value, setValue] = useState(''); + // The field list can be empty (no schema and no returned rows), so a filter + // is only submitted with a field that is actually on offer. + const selected = fields.includes(field) ? field : fields[0] || ''; + function add(e: FormEvent) { + e.preventDefault(); + if (!selected) return; + const filter = { id: editing || createId(), field: selected, operator, value }; + if (editing && onUpdate) onUpdate(filter); + else onAdd(filter); + setOpen(false); + setValue(''); + } + return ( + <> +
+
+
+ +
+ {filters.length > 0 && ( +
+ {filters.map((f) => ( + + + + + ))} + {onClear && ( + + )} +
+ )} + +
+ + + setValue(e.target.value)} required /> + +
+
+ + ); +} diff --git a/frontend/src/features/logs/LogsPage.tsx b/frontend/src/features/logs/LogsPage.tsx new file mode 100644 index 000000000..65cc7d178 --- /dev/null +++ b/frontend/src/features/logs/LogsPage.tsx @@ -0,0 +1,267 @@ +import { createPortal } from 'react-dom'; +import { createId } from '../../lib/ids'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { PanelLeft, RefreshCw, Terminal, WrapText, Download } from 'lucide-react'; +import { Button, EmptyState, Select } from '../../components/ui'; +import { QueryState } from '../../components/explorer/QueryState'; +import { TimeRangePicker } from '../../components/explorer/TimeRangePicker'; +import { ColumnPicker } from '../../components/explorer/ColumnPicker'; +import { DataTable, exportRecords } from '../../components/explorer/DataTable'; +import { LogHistogram } from '../../components/explorer/LogHistogram'; +import { RecordSheet } from '../../components/explorer/RecordSheet'; +import { FieldSidebar } from './FieldSidebar'; +import { FilterBar, type LogFilter } from './FilterBar'; +import { useApp } from '../../app/AppProvider'; +import { ApiError } from '../../lib/client'; +import { useAsync } from '../../hooks/useAsync'; +import { buildLogQuery, timeBounds } from '../../lib/query'; +import type { LogRecord, TimeRange } from '../../lib/types'; +export function LogsPage() { + const { client } = useApp(); + const { currentDataset } = useParams(); + const navigate = useNavigate(); + const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); + const dataset = currentDataset || datasets.data?.[0]?.name || ''; + const [range, setRange] = useState('1h'); + const [search, setSearch] = useState(''); + // Tagged with its dataset so a timer left over from the previous dataset is ignored. + const [debounced, setDebounced] = useState({ dataset, search: '' }); + const [filters, setFilters] = useState([]); + const [columns, setColumns] = useState(); + const [showFields, setShowFields] = useState(true); + const [wrap, setWrap] = useState(false); + const [showHistogram, setShowHistogram] = useState(true); + const [row, setRow] = useState(); + // Fields seen in the latest returned rows, kept while the next results load + // or if they fail, so the field list does not empty out. Reset with the dataset. + const [rowFields, setRowFields] = useState<{ rows?: LogRecord[]; fields: string[] }>({ + fields: [], + }); + useEffect(() => { + const timer = setTimeout(() => setDebounced({ dataset, search }), 250); + return () => clearTimeout(timer); + }, [dataset, search]); + // Reset during render, not in an effect, so the query never runs with the + // previous dataset's filters against the new one. + const [stateDataset, setStateDataset] = useState(dataset); + if (stateDataset !== dataset) { + setStateDataset(dataset); + setFilters([]); + setColumns(undefined); + setSearch(''); + setDebounced({ dataset, search: '' }); + setRow(undefined); + setRowFields({ fields: [] }); + } + const schema = useAsync( + useCallback( + (signal) => (dataset ? client.schema(dataset, signal) : Promise.resolve([])), + [client, dataset], + ), + ); + const debouncedSearch = debounced.dataset === dataset ? debounced.search : ''; + const sql = useMemo( + () => (dataset ? buildLogQuery(dataset, debouncedSearch, filters, 100) : ''), + [dataset, debouncedSearch, filters], + ); + const results = useAsync( + useCallback( + (signal) => (sql ? client.query({ sql, ...timeBounds(range) }, signal) : Promise.resolve([])), + [client, sql, range], + ), + ); + if (results.data && rowFields.rows !== results.data) + setRowFields({ rows: results.data, fields: [...new Set(results.data.flatMap(Object.keys))] }); + const fields = schema.data?.length ? schema.data : rowFields.fields; + const visibleColumns = columns ?? ['p_timestamp'].filter((f) => fields.includes(f)); + // An identical filter changes nothing, so it is not added twice. + const addFilter = (filter: LogFilter) => + setFilters((items) => + items.some( + (f) => + f.field === filter.field && f.operator === filter.operator && f.value === filter.value, + ) + ? items + : [...items, filter], + ); + return ( +
+

Logs

+ {(() => { + const selector = ( + + ); + const slot = document.getElementById('workspace-context'); + return slot ? ( + createPortal(selector, slot) + ) : ( +
{selector}
+ ); + })()} + + {!datasets.loading && !datasets.error && !dataset && ( + + )} + {!!dataset && !datasets.error && ( +
+ {showFields && ( + + setColumns( + visibleColumns.includes(field) + ? visibleColumns.length > 1 + ? visibleColumns.filter((f) => f !== field) + : visibleColumns + : [...visibleColumns, field], + ) + } + /> + )} +
+
+ +
+ + +
+
+
+ setFilters(filters.filter((f) => f.id !== id))} + onUpdate={(filter) => + setFilters(filters.map((f) => (f.id === filter.id ? filter : f))) + } + onClear={() => { + setFilters([]); + setSearch(''); + }} + /> + + + +
+ <> + {schema.error && ( +

+ {schema.error instanceof ApiError && schema.error.status === 403 + ? 'Permission denied for field schema.' + : 'Field schema unavailable.'}{' '} + Showing fields from returned events.{' '} + +

+ )} + + + {!results.loading && !results.error && ( + <> + {showHistogram && ( + + )} +
+

{results.data?.length || 0} records

+
+ + + + + +
+
+ + + )} +
+
+ )} + setRow(undefined)} + onFilter={(field, value, operator = '=') => + addFilter({ id: createId(), field, operator, value }) + } + /> +
+ ); +} diff --git a/frontend/src/features/sql/SqlEditor.tsx b/frontend/src/features/sql/SqlEditor.tsx new file mode 100644 index 000000000..7ca517937 --- /dev/null +++ b/frontend/src/features/sql/SqlEditor.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react'; +import { EditorView, keymap } from '@codemirror/view'; +import { EditorState, Prec } from '@codemirror/state'; +import { sql } from '@codemirror/lang-sql'; +import { basicSetup } from 'codemirror'; +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { tags } from '@lezer/highlight'; +export function SqlEditor({ + value, + onChange, + onRun, +}: { + value: string; + onChange: (value: string) => void; + onRun: () => void; +}) { + const element = useRef(null); + const view = useRef(null); + const callbacks = useRef({ onChange, onRun }); + callbacks.current = { onChange, onRun }; + useEffect(() => { + const editor = new EditorView({ + parent: element.current!, + state: EditorState.create({ + doc: value, + extensions: [ + basicSetup, + sql(), + Prec.highest( + keymap.of([ + { + key: 'Mod-Enter', + run: () => { + callbacks.current.onRun(); + return true; + }, + }, + ]), + ), + syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.keyword, color: 'var(--color-primary)', fontWeight: '600' }, + { tag: [tags.string, tags.name], color: 'var(--color-success)' }, + { tag: tags.number, color: 'var(--color-warning)' }, + { tag: tags.comment, color: 'var(--color-muted)' }, + ]), + ), + EditorView.contentAttributes.of({ 'aria-label': 'SQL query' }), + EditorView.updateListener.of((update) => { + if (update.docChanged) callbacks.current.onChange(update.state.doc.toString()); + }), + EditorView.theme({ + '&': { + backgroundColor: 'var(--color-surface)', + color: 'var(--color-text)', + fontSize: '13px', + minHeight: '220px', + }, + '.cm-content': { fontFamily: 'var(--font-mono)', padding: '18px 0' }, + '.cm-gutters': { + backgroundColor: 'var(--color-bg)', + color: 'var(--color-muted)', + borderRight: '1px solid var(--color-border)', + }, + '.cm-activeLine': { backgroundColor: 'var(--color-primary-soft)' }, + '.cm-activeLineGutter': { backgroundColor: 'var(--color-primary-soft)' }, + '.cm-cursor': { borderLeftColor: 'var(--color-text)' }, + }), + ], + }), + }); + view.current = editor; + return () => { + view.current = null; + editor.destroy(); + }; + }, []); + useEffect(() => { + const editor = view.current; + if (editor && editor.state.doc.toString() !== value) + editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } }); + }, [value]); + return
; +} diff --git a/frontend/src/features/sql/SqlPage.tsx b/frontend/src/features/sql/SqlPage.tsx new file mode 100644 index 000000000..8cd875e2a --- /dev/null +++ b/frontend/src/features/sql/SqlPage.tsx @@ -0,0 +1,168 @@ +import { useCallback, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { Play, Download, WrapText, Database } from 'lucide-react'; +import { Button, Card, EmptyState, Input } from '../../components/ui'; +import { PageHeader } from '../../components/explorer/PageHeader'; +import { ColumnPicker } from '../../components/explorer/ColumnPicker'; +import { DataTable, exportRecords } from '../../components/explorer/DataTable'; +import { QueryState } from '../../components/explorer/QueryState'; +import { RecordSheet } from '../../components/explorer/RecordSheet'; +import { TimeRangePicker } from '../../components/explorer/TimeRangePicker'; +import { SqlEditor } from './SqlEditor'; +import { useApp } from '../../app/AppProvider'; +import { useAsync } from '../../hooks/useAsync'; +import { timeBounds, quoteIdentifier } from '../../lib/query'; +import type { LogRecord, TimeRange } from '../../lib/types'; +export function SqlPage() { + const { client, mode } = useApp(); + const [params] = useSearchParams(); + const [query, setQuery] = useState( + params.get('query') || + (mode === 'demo' + ? 'SELECT * FROM "application_logs"\nORDER BY "p_timestamp" DESC\nLIMIT 100' + : ''), + ); + const [range, setRange] = useState('1h'); + const [request, setRequest] = useState<{ sql: string; startTime: string; endTime: string }>(); + const [row, setRow] = useState(); + const [wrap, setWrap] = useState(false); + const [columns, setColumns] = useState(); + const [datasetSearch, setDatasetSearch] = useState(''); + const datasets = useAsync(useCallback((signal) => client.listDatasets(signal), [client])); + const results = useAsync( + useCallback( + (signal) => (request ? client.query(request, signal) : Promise.resolve([])), + [request, client], + ), + ); + const run = () => { + if (query.trim()) { + setColumns(undefined); + setRequest({ sql: query, ...timeBounds(range) }); + } + }; + return ( +
+
Query 1
+ } + /> +
+ +
+ +
+

Query

+ ⌘ / Ctrl + Enter to run +
+ +
+ + {mode === 'demo' + ? 'Demo supports SELECT *, exact filters, message search, ordering, and limits.' + : 'Queries run against your connected Parseable server.'} + + +
+
+ +
+

Results

+
+ + + + +
+
+ {!request ? ( + + ) : ( + <> + + {!results.loading && !results.error && ( + + )} + + )} +
+
+
+ setRow(undefined)} /> +
+ ); +} diff --git a/frontend/src/hooks/useAsync.ts b/frontend/src/hooks/useAsync.ts new file mode 100644 index 000000000..917ebd75a --- /dev/null +++ b/frontend/src/hooks/useAsync.ts @@ -0,0 +1,36 @@ +import { useEffect, useState } from 'react'; +// Callers memoize the loader: replacing it cancels the previous request. +export function useAsync(loader: (signal: AbortSignal) => Promise) { + const [revision, setRevision] = useState(0); + // Tagged with the request it answers, so a result never outlives its loader: + // until the new request's effect runs, the stale result reads as loading. + const [state, setState] = useState<{ + data?: T; + error?: Error; + loader?: typeof loader; + revision?: number; + }>({}); + useEffect(() => { + const abort = new AbortController(); + loader(abort.signal) + .then((data) => { + if (!abort.signal.aborted) setState({ data, loader, revision }); + }) + .catch((error) => { + if (!abort.signal.aborted) + setState({ + error: error instanceof Error ? error : new Error(String(error)), + loader, + revision, + }); + }); + return () => abort.abort(); + }, [loader, revision]); + const current = state.loader === loader && state.revision === revision; + return { + data: current ? state.data : undefined, + error: current ? state.error : undefined, + loading: !current, + reload: () => setRevision((v) => v + 1), + }; +} diff --git a/frontend/src/lib/auth.test.ts b/frontend/src/lib/auth.test.ts new file mode 100644 index 000000000..c1235ed65 --- /dev/null +++ b/frontend/src/lib/auth.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cookieIdentity, safeReturnPath, startSso } from './auth'; +afterEach(() => vi.unstubAllGlobals()); + +describe('safe authentication navigation', () => { + it.each([ + 'https://evil.test/logs', + '//evil.test/logs', + '/\\evil.test', + '/api/v1/o/logout', + '/login?next=/logs', + '/oidc-not-configured', + '/\n/evil.test', + ])('rejects unsafe return path %s', (path) => { + expect(safeReturnPath(path, 'https://logs.example.test')).toBe('/'); + }); + it('preserves a local application path, filters and fragment', () => { + expect( + safeReturnPath('/logs/explore/team%20logs?range=1h#event', 'https://logs.example.test'), + ).toBe('/logs/explore/team%20logs?range=1h#event'); + }); + it('starts OAuth as a document navigation and remembers the intended local route', () => { + const assign = vi.fn(); + const setItem = vi.fn(); + vi.stubGlobal('window', { location: { origin: 'https://logs.example.test', assign } }); + vi.stubGlobal('sessionStorage', { setItem }); + vi.stubGlobal('document', { cookie: 'session=expired' }); + startSso('/logs?range=1h'); + expect(assign).toHaveBeenCalledWith( + '/api/v1/o/login?redirect=https%3A%2F%2Flogs.example.test%2Flogs%3Frange%3D1h', + ); + expect(setItem).toHaveBeenCalledWith('parseable-return-path', '/logs?range=1h'); + expect(setItem).toHaveBeenCalledWith('parseable-mode', 'live'); + }); + it('handles cookie hints without requiring both display cookies', () => { + expect(cookieIdentity('user_id=team%2Freader')).toEqual({ + id: 'team/reader', + username: 'team/reader', + source: 'cookie', + }); + expect(cookieIdentity('session=anything')).toBeUndefined(); + }); +}); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 000000000..15b9c6dde --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -0,0 +1,72 @@ +import type { SessionIdentity } from './types'; +import { appPath } from './config'; + +const RETURN_PATH_KEY = 'parseable-return-path'; + +/** Accept only local application paths, never authentication endpoints or external origins. */ +export function safeReturnPath( + value: string | null | undefined, + origin = window.location.origin, +): string { + if (!value || !value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u001f]/.test(value)) + return '/'; + try { + const url = new URL(value, origin); + if ( + url.origin !== origin || + /^\/(?:api(?:\/|$)|login(?:\/|$)|oidc-not-configured(?:\/|$))/.test(url.pathname) + ) + return '/'; + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return '/'; + } +} + +export function authEndpoint(action: 'login' | 'logout', path: string): string { + const destination = action === 'logout' ? '/login' : safeReturnPath(path); + const redirect = new URL(appPath(destination), window.location.origin).href; + return `/api/v1/o/${action}?${new URLSearchParams({ redirect })}`; +} + +export function rememberReturnPath(path: string) { + sessionStorage.setItem(RETURN_PATH_KEY, safeReturnPath(path)); +} + +export function intendedReturnPath(search: string): string { + const next = new URLSearchParams(search).get('next'); + return safeReturnPath(next ?? sessionStorage.getItem(RETURN_PATH_KEY)); +} + +export function startSso(path: string) { + rememberReturnPath(path); + sessionStorage.setItem('parseable-mode', 'live'); + clearSessionCookies(); + window.location.assign(authEndpoint('login', path)); +} + +/** Display-only hints; cookies never establish authentication or permissions. */ +export function cookieIdentity(cookie = document.cookie): SessionIdentity | undefined { + const values = new Map( + cookie.split(';').map((part) => { + const split = part.indexOf('='); + const key = part.slice(0, split).trim(); + const value = part.slice(split + 1).trim(); + try { + return [key, decodeURIComponent(value)] as const; + } catch { + return [key, value] as const; + } + }), + ); + const id = values.get('user_id') || values.get('username'); + if (!id) return undefined; + return { id, username: values.get('username') || id, source: 'cookie' }; +} + +/** The server currently leaves expired session cookies in the browser. */ +export function clearSessionCookies() { + for (const name of ['session', 'username', 'user_id']) { + document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`; + } +} diff --git a/frontend/src/lib/classicUi.test.ts b/frontend/src/lib/classicUi.test.ts new file mode 100644 index 000000000..48c80e516 --- /dev/null +++ b/frontend/src/lib/classicUi.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { classicUiPath } from './classicUi'; +import { appPath } from './config'; + +describe('side-by-side classic UI', () => { + it.each([ + ['/', '/'], + ['/logs', '/'], + ['/logs/explore/team%20logs', '/logs/explore/team%20logs'], + ['/sql-editor', '/sql-editor'], + ['/datasets', '/datasets'], + ['/dashboards', '/dashboards'], + ['/components', '/'], + ])('maps %s to the classic page %s', (route, classic) => { + expect(classicUiPath(route)).toBe(classic); + }); + it('prefixes document navigations with the mount path', () => { + expect(appPath('/login', '/next')).toBe('/next/login'); + expect(appPath('/login', '')).toBe('/login'); + }); +}); diff --git a/frontend/src/lib/classicUi.ts b/frontend/src/lib/classicUi.ts new file mode 100644 index 000000000..8c12c98d1 --- /dev/null +++ b/frontend/src/lib/classicUi.ts @@ -0,0 +1,20 @@ +import { basePath } from './config'; + +/** The classic (Prism) UI owns the root while this frontend is mounted under a prefix. */ +export const classicUiAvailable = basePath !== ''; + +/** Classic UI pages for features this frontend does not provide yet. */ +export const classicOnlyPages = [ + { href: '/alerts', label: 'Alerts', id: 'alerts' }, + { href: '/team?tab=user', label: 'Users', id: 'users' }, + { href: '/team?tab=roles', label: 'Roles', id: 'roles' }, +] as const; + +/** The classic UI page equivalent to a route in this frontend, for comparison. */ +export function classicUiPath(pathname: string): string { + const [, section, view, dataset] = pathname.split('/'); + if (section === 'logs' && view && dataset) return `/logs/explore/${dataset}`; + if (section === 'sql-editor' || section === 'datasets' || section === 'dashboards') + return `/${section}`; + return '/'; +} diff --git a/frontend/src/lib/client.test.ts b/frontend/src/lib/client.test.ts new file mode 100644 index 000000000..5668134ed --- /dev/null +++ b/frontend/src/lib/client.test.ts @@ -0,0 +1,207 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ApiError, createClient } from './client'; + +afterEach(() => vi.unstubAllGlobals()); +const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); + +describe('live HTTP contract', () => { + it('uses backend endpoints, cookie credentials, abort signal and Arrow field names', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(json([{ name: 'api_logs' }])) + .mockResolvedValueOnce( + json({ fields: [{ name: 'p_timestamp' }, { name: 'message' }], metadata: {} }), + ); + vi.stubGlobal('fetch', fetch); + const signal = new AbortController().signal; + const client = createClient({ mode: 'live' }); + expect(await client.listDatasets(signal)).toEqual([{ name: 'api_logs', type: 'logs' }]); + expect(await client.schema('logs / staging', signal)).toEqual(['p_timestamp', 'message']); + expect(fetch).toHaveBeenNthCalledWith(1, '/api/v1/logstream', { + signal, + credentials: 'include', + }); + expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/logstream/logs%20%2F%20staging/schema', { + signal, + credentials: 'include', + }); + }); + it('translates query request into the Rust camelCase contract', async () => { + const fetch = vi.fn().mockResolvedValue(json([{ message: 'ok', optional: null }])); + vi.stubGlobal('fetch', fetch); + const result = await createClient({ mode: 'live' }).query({ + sql: 'SELECT * FROM logs', + startTime: 'start', + endTime: 'end', + }); + expect(result).toEqual([{ message: 'ok', optional: null }]); + const [url, init] = fetch.mock.calls[0]; + expect(url).toBe('/api/v1/query'); + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body)).toEqual({ + query: 'SELECT * FROM logs', + startTime: 'start', + endTime: 'end', + sendNull: true, + }); + }); + it('preserves HTTP status and human readable error bodies', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(new Response('Permission denied', { status: 403 })), + ); + await expect(createClient({ mode: 'live' }).listDatasets()).rejects.toMatchObject({ + name: 'ApiError', + status: 403, + message: 'Permission denied', + }); + }); + it('rejects malformed success responses', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(json({ records: [] }))); + await expect( + createClient({ mode: 'live' }).query({ sql: 'select', startTime: '', endTime: '' }), + ).rejects.toBeInstanceOf(ApiError); + }); + it('exchanges Basic credentials, then verifies cookie auth without retaining a password', async () => { + vi.stubGlobal('window', { location: { origin: 'http://localhost:5173' } }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('app')) + .mockResolvedValueOnce(json([])); + vi.stubGlobal('fetch', fetch); + await createClient({ mode: 'live' }).login('admin', 'secret'); + expect(fetch.mock.calls[0][0]).toBe( + '/api/v1/o/login?redirect=http%3A%2F%2Flocalhost%3A5173%2F', + ); + expect(fetch.mock.calls[0][1].headers.Authorization).toBe(`Basic ${btoa('admin:secret')}`); + expect(fetch.mock.calls[1]).toEqual(['/api/v1/logstream', { credentials: 'include' }]); + }); + it('forwards network and abort failures', async () => { + const error = new DOMException('Aborted', 'AbortError'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error)); + await expect(createClient({ mode: 'live' }).listDatasets()).rejects.toBe(error); + }); +}); + +describe('demo contract', () => { + it('requires no network and honors cancellation', async () => { + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); + const client = createClient({ mode: 'demo' }); + expect(await client.listDatasets()).toHaveLength(3); + expect(await client.schema('application_logs')).toContain('message'); + const controller = new AbortController(); + controller.abort(); + await expect(client.listDatasets(controller.signal)).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(fetch).not.toHaveBeenCalled(); + }); +}); + +describe('session and navigation contracts', () => { + it('prefers the authorized user API identity over cookie display hints', async () => { + vi.stubGlobal('document', { cookie: 'username=Old%20Name; user_id=team%2Freader' }); + const fetch = vi.fn().mockResolvedValue( + json({ + id: 'team/reader', + username: 'Current Name', + method: 'oauth', + email: 'reader@example.test', + }), + ); + vi.stubGlobal('fetch', fetch); + const identity = await createClient({ mode: 'live' }).identity(); + expect(identity).toEqual({ + id: 'team/reader', + username: 'Current Name', + method: 'oauth', + email: 'reader@example.test', + source: 'server', + }); + expect(fetch).toHaveBeenCalledWith('/api/v1/users/team%2Freader', { + signal: undefined, + credentials: 'include', + }); + }); + it.each([403, 404, 503])( + 'falls back to display cookies when the identity API returns %s', + async (status) => { + vi.stubGlobal('document', { cookie: 'username=Reader; user_id=reader' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Unavailable', { status }))); + expect(await createClient({ mode: 'live' }).identity()).toEqual({ + id: 'reader', + username: 'Reader', + source: 'cookie', + }); + }, + ); + it('does not trust a stale identity cookie after the server returns 401', async () => { + vi.stubGlobal('document', { cookie: 'username=Reader; user_id=reader' }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('Expired', { status: 401 }))); + const onUnauthorized = vi.fn(); + await expect(createClient({ mode: 'live', onUnauthorized }).identity()).rejects.toMatchObject({ + status: 401, + }); + expect(onUnauthorized).toHaveBeenCalledOnce(); + }); + it('notifies session expiry for query requests, but preserves permission errors', async () => { + const onUnauthorized = vi.fn(); + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce(new Response('Expired', { status: 401 })) + .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })), + ); + const client = createClient({ mode: 'live', onUnauthorized }); + await expect( + client.query({ sql: 'SELECT 1', startTime: '', endTime: '' }), + ).rejects.toMatchObject({ status: 401 }); + await expect(client.schema('logs')).rejects.toMatchObject({ status: 403 }); + expect(onUnauthorized).toHaveBeenCalledOnce(); + }); + it('accepts a cookie-authenticated login with restricted stream-list permissions', async () => { + vi.stubGlobal('window', { location: { origin: 'https://logs.example.test' } }); + const fetch = vi + .fn() + .mockResolvedValueOnce(new Response('app')) + .mockResolvedValueOnce(new Response('Forbidden', { status: 403 })); + vi.stubGlobal('fetch', fetch); + await createClient({ mode: 'live' }).login( + 'reader', + 'secret', + '/sql-editor?dataset=logs#query', + ); + const url = new URL(fetch.mock.calls[0][0], 'https://logs.example.test'); + expect(url.searchParams.get('redirect')).toBe( + 'https://logs.example.test/sql-editor?dataset=logs#query', + ); + }); + it('navigates the browser through logout so provider redirects can complete', async () => { + const assign = vi.fn(); + const fetch = vi.fn(); + vi.stubGlobal('window', { location: { origin: 'https://logs.example.test', assign } }); + vi.stubGlobal('fetch', fetch); + await createClient({ mode: 'live' }).logout(); + expect(assign).toHaveBeenCalledWith( + '/api/v1/o/logout?redirect=https%3A%2F%2Flogs.example.test%2Flogin', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + it('rejects production demo clients unless the build explicitly enables sample data', async () => { + vi.stubEnv('DEV', false); + vi.stubEnv('VITE_ENABLE_DEMO', 'false'); + vi.resetModules(); + const { createClient: productionClient } = await import('./client'); + const fetch = vi.fn().mockResolvedValue(json([{ name: 'real_logs' }])); + vi.stubGlobal('fetch', fetch); + expect(await productionClient({ mode: 'demo' }).listDatasets()).toEqual([ + { name: 'real_logs', type: 'logs' }, + ]); + expect(fetch).toHaveBeenCalledOnce(); + vi.unstubAllEnvs(); + vi.resetModules(); + }); +}); diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts new file mode 100644 index 000000000..254796481 --- /dev/null +++ b/frontend/src/lib/client.ts @@ -0,0 +1,155 @@ +import { createDemoClient } from './demo'; +import { authEndpoint, cookieIdentity } from './auth'; +import { demoEnabled } from './config'; +import type { Dataset, LogRecord, ParseableClient } from './types'; + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly detail?: unknown, + ) { + super(message); + this.name = 'ApiError'; + } +} + +function object(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +async function checked(response: Response): Promise { + if (!response.ok) { + const body = await response.text(); + let detail: unknown = body; + try { + detail = JSON.parse(body); + } catch { + /* Backend errors can be plain text. */ + } + const message = + object(detail) && typeof detail.message === 'string' + ? detail.message + : object(detail) && typeof detail.error === 'string' + ? detail.error + : body || response.statusText; + throw new ApiError(message || `Request failed (${response.status})`, response.status, detail); + } + return response; +} + +function malformed(endpoint: string): never { + throw new ApiError(`Unexpected response from ${endpoint}`, 502); +} + +async function readJson( + endpoint: string, + init: RequestInit, + onUnauthorized?: () => void, +): Promise { + const received = await fetch(endpoint, { ...init, credentials: 'include' }); + if (received.status === 401) onUnauthorized?.(); + const response = await checked(received); + try { + return await response.json(); + } catch { + return malformed(endpoint); + } +} + +export function createClient({ + mode, + onUnauthorized, +}: { + mode: 'demo' | 'live'; + onUnauthorized?: () => void; +}): ParseableClient { + if (mode === 'demo' && demoEnabled) return createDemoClient(); + const base = '/api/v1'; + return { + async listDatasets(signal) { + const endpoint = `${base}/logstream`; + const data = await readJson(endpoint, { signal }, onUnauthorized); + if ( + !Array.isArray(data) || + !data.every((item) => object(item) && typeof item.name === 'string') + ) + return malformed(endpoint); + // This endpoint returns names only. Do not infer telemetry type from a name. + return data.map((item) => ({ name: item.name, type: 'logs' }) satisfies Dataset); + }, + async schema(dataset, signal) { + const endpoint = `${base}/logstream/${encodeURIComponent(dataset)}/schema`; + const data = await readJson(endpoint, { signal }, onUnauthorized); + if ( + !object(data) || + !Array.isArray(data.fields) || + !data.fields.every((field) => object(field) && typeof field.name === 'string') + ) + return malformed(endpoint); + return data.fields.map((field) => field.name as string); + }, + async query({ sql, startTime, endTime }, signal) { + const endpoint = `${base}/query`; + const data = await readJson( + endpoint, + { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: sql, startTime, endTime, sendNull: true }), + }, + onUnauthorized, + ); + if (!Array.isArray(data) || !data.every(object)) return malformed(endpoint); + return data as LogRecord[]; + }, + async identity(signal) { + const fallback = cookieIdentity(); + if (!fallback) return undefined; + const endpoint = `${base}/users/${encodeURIComponent(fallback.id)}`; + try { + const data = await readJson(endpoint, { signal }, onUnauthorized); + if (!object(data) || typeof data.id !== 'string' || typeof data.username !== 'string') + return malformed(endpoint); + return { + id: data.id, + username: data.username, + method: typeof data.method === 'string' ? data.method : undefined, + email: typeof data.email === 'string' ? data.email : undefined, + source: 'server', + }; + } catch (error) { + if (signal?.aborted || (error instanceof ApiError && error.status === 401)) throw error; + // Identity hints are display-only; a missing/unavailable profile must not + // make the workspace unusable or grant access to protected requests. + return fallback; + } + }, + async login(username, password, returnPath = '/') { + if (!username || username.includes(':')) + throw new ApiError('Enter a username without a colon', 400); + const bytes = new TextEncoder().encode(`${username}:${password}`); + const credentials = btoa(Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')); + await checked( + await fetch(authEndpoint('login', returnPath), { + credentials: 'include', + headers: { Authorization: `Basic ${credentials}` }, + redirect: 'follow', + }), + ); + // Verify the exchanged cookie independently; never persist the password or + // resend Basic auth with data requests. + try { + await readJson(`${base}/logstream`, {}); + } catch (error) { + // A restricted account may sign in successfully without stream-list access. + if (!(error instanceof ApiError && error.status === 403)) throw error; + } + }, + async logout() { + // OIDC logout can cross origins; a fetch cannot complete that browser flow. + window.location.assign(authEndpoint('logout', '/login')); + }, + }; +} diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts new file mode 100644 index 000000000..848b0c97d --- /dev/null +++ b/frontend/src/lib/config.ts @@ -0,0 +1,10 @@ +/** Sample workspaces are an explicit development/build capability. */ +export const demoEnabled = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEMO === 'true'; + +/** Router base without a trailing slash; `/next` while served beside the classic UI. */ +export const basePath = import.meta.env.BASE_URL.replace(/\/$/, ''); + +/** Document path for an application route, for navigations outside the router. */ +export function appPath(path: string, base = basePath): string { + return `${base}${path}`; +} diff --git a/frontend/src/lib/demo.ts b/frontend/src/lib/demo.ts new file mode 100644 index 000000000..623373ff1 --- /dev/null +++ b/frontend/src/lib/demo.ts @@ -0,0 +1,239 @@ +import type { Dataset, LogRecord, ParseableClient, QueryRequest } from './types'; + +export const demoDatasets: Dataset[] = [ + { name: 'application_logs', type: 'logs' }, + { name: 'api_logs', type: 'logs' }, + { name: 'infrastructure_logs', type: 'logs' }, +]; + +/** Fixed sequences anchored to now keep the initial relative time range useful. */ +export function createDemoRecords(now: number = Date.now()): LogRecord[] { + const services = ['api-gateway', 'checkout', 'inventory', 'auth', 'worker']; + const messages = [ + 'Request completed successfully', + 'Order created and queued for fulfillment', + 'Inventory cache refreshed', + 'Session token validated', + 'Background job completed', + ]; + return Array.from({ length: 100 }, (_, index) => { + const level = index % 13 === 0 ? 'ERROR' : index % 7 === 0 ? 'WARN' : 'INFO'; + return { + p_timestamp: new Date(now - index * 8_000).toISOString(), + level, + service: services[index % services.length], + message: + level === 'ERROR' + ? 'Upstream request timed out after 3000ms' + : level === 'WARN' + ? 'Retrying request after a transient connection failure' + : messages[index % messages.length], + host: `prod-node-${String((index % 4) + 1).padStart(2, '0')}`, + environment: 'production', + region: index % 3 === 0 ? 'eu-west-1' : 'us-east-1', + method: index % 3 === 0 ? 'POST' : 'GET', + path: ['/api/orders', '/api/products', '/health', '/api/session'][index % 4], + status_code: level === 'ERROR' ? 504 : level === 'WARN' ? 429 : 200, + duration_ms: level === 'ERROR' ? 3000 : 12 + ((index * 37) % 240), + trace_id: `8e74f620af734b9086bb${index.toString(16).padStart(12, '0')}`, + }; + }); +} + +export const demoLogs = createDemoRecords(); +type Token = { kind: 'identifier' | 'string' | 'word' | 'number' | 'symbol'; value: string }; +const unsupported = () => + new Error( + 'Demo SQL supports SELECT * FROM a sample dataset, AND filters (= or !=), message ILIKE search, ORDER BY and LIMIT. Connect to a server for other SQL.', + ); + +function tokenize(sql: string): Token[] { + const tokens: Token[] = []; + const expression = + /\s+|"(?:[^"]|"")*"|'(?:[^']|'')*'|!=|<>|[=*,();]|[A-Za-z_][A-Za-z_0-9]*|\d+/gy; + let offset = 0; + while (offset < sql.length) { + expression.lastIndex = offset; + const match = expression.exec(sql); + if (!match) throw unsupported(); + const value = match[0]; + offset = expression.lastIndex; + if (/^\s+$/.test(value)) continue; + if (value[0] === '"') + tokens.push({ kind: 'identifier', value: value.slice(1, -1).replaceAll('""', '"') }); + else if (value[0] === "'") + tokens.push({ kind: 'string', value: value.slice(1, -1).replaceAll("''", "'") }); + else if (/^\d+$/.test(value)) tokens.push({ kind: 'number', value }); + else if (/^[A-Za-z_]/.test(value)) tokens.push({ kind: 'word', value }); + else tokens.push({ kind: 'symbol', value }); + } + return tokens; +} + +function likeRegex(pattern: string, escape: string): RegExp { + let expression = ''; + for (let index = 0; index < pattern.length; index++) { + let character = pattern[index]; + if (character === escape) { + character = pattern[++index]; + if (character === undefined) throw unsupported(); + } else if (character === '%') { + expression += '[\\s\\S]*'; + continue; + } else if (character === '_') { + expression += '[\\s\\S]'; + continue; + } + expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + return new RegExp(`^${expression}$`, 'i'); +} + +export function executeDemoQuery(request: QueryRequest, records: LogRecord[]): LogRecord[] { + const tokens = tokenize(request.sql); + let position = 0; + const peek = (value: string) => + ['word', 'symbol'].includes(tokens[position]?.kind) && + tokens[position].value.toUpperCase() === value; + const expect = (value: string) => { + if (!peek(value)) throw unsupported(); + position++; + }; + const identifier = () => { + const token = tokens[position++]; + if (!token || !['word', 'identifier'].includes(token.kind)) throw unsupported(); + return token.kind === 'word' ? token.value.toLowerCase() : token.value; + }; + const literal = () => { + const token = tokens[position++]; + if (token?.kind !== 'string') throw unsupported(); + return token.value; + }; + const field = () => { + const name = identifier(); + if (records.length && !Object.hasOwn(records[0], name)) + throw new Error(`Unknown demo field: ${name}`); + return name; + }; + expect('SELECT'); + expect('*'); + expect('FROM'); + const dataset = identifier(); + if (!demoDatasets.some((item) => item.name === dataset)) + throw new Error(`Unknown demo dataset: ${dataset}`); + const predicates: ((row: LogRecord) => boolean)[] = []; + if (peek('WHERE')) { + position++; + let firstCondition = true; + do { + if (!firstCondition) expect('AND'); + firstCondition = false; + if (peek('AND')) throw unsupported(); + if (peek('CAST')) { + position++; + expect('('); + const name = field(); + expect('AS'); + expect('VARCHAR'); + expect(')'); + expect('ILIKE'); + const pattern = literal(); + expect('ESCAPE'); + const escape = literal(); + if (escape.length !== 1) throw unsupported(); + const matcher = likeRegex(pattern, escape); + predicates.push((row) => row[name] != null && matcher.test(String(row[name]))); + } else { + const name = field(); + const operation = tokens[position++]?.value; + if (!['=', '!=', '<>'].includes(operation)) throw unsupported(); + const token = tokens[position++]; + if (!token || !['string', 'number'].includes(token.kind)) throw unsupported(); + const value = token.value; + predicates.push( + (row) => + row[name] != null && + (operation === '=' ? String(row[name]) === value : String(row[name]) !== value), + ); + } + } while (peek('AND')); + } + let orderField: string | undefined; + let descending = false; + if (peek('ORDER')) { + position++; + expect('BY'); + orderField = field(); + if (peek('DESC')) { + descending = true; + position++; + } else if (peek('ASC')) position++; + } + let limit = 100; + if (peek('LIMIT')) { + position++; + const token = tokens[position++]; + if (token?.kind !== 'number') throw unsupported(); + limit = Number(token.value); + if (!Number.isSafeInteger(limit) || limit > 10_000) + throw new Error('Demo limit must be between 0 and 10000'); + } + if (peek(';')) position++; + if (position !== tokens.length) throw unsupported(); + const start = Date.parse(request.startTime), + end = Date.parse(request.endTime); + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) + throw new Error('Invalid query time range'); + const result = records.filter((row) => { + const timestamp = Date.parse(String(row.p_timestamp)); + return timestamp >= start && timestamp < end && predicates.every((predicate) => predicate(row)); + }); + if (orderField) { + const key = orderField; + result.sort((a, b) => { + const av = a[key], + bv = b[key]; + const comparison = + typeof av === 'number' && typeof bv === 'number' + ? av - bv + : String(av).localeCompare(String(bv)); + return descending ? -comparison : comparison; + }); + } + return result.slice(0, limit).map((row) => ({ ...row })); +} + +export function createDemoClient(): ParseableClient { + const records = createDemoRecords(Date.now() - 1_000); + async function ready(signal?: AbortSignal) { + signal?.throwIfAborted(); + await Promise.resolve(); + signal?.throwIfAborted(); + } + return { + async listDatasets(signal) { + await ready(signal); + return demoDatasets.map((dataset) => ({ ...dataset })); + }, + async schema(dataset, signal) { + await ready(signal); + if (!demoDatasets.some((item) => item.name === dataset)) + throw new Error(`Unknown demo dataset: ${dataset}`); + return Object.keys(records[0]); + }, + async query(request, signal) { + await ready(signal); + return executeDemoQuery(request, records); + }, + async identity(signal) { + await ready(signal); + return { id: 'demo', username: 'Demo user', source: 'demo' }; + }, + async login() { + await ready(); + }, + async logout() { + await ready(); + }, + }; +} diff --git a/frontend/src/lib/ids.ts b/frontend/src/lib/ids.ts new file mode 100644 index 000000000..8bc177511 --- /dev/null +++ b/frontend/src/lib/ids.ts @@ -0,0 +1,5 @@ +/** Local model IDs also work on HTTP previews accessed through a tailnet. */ +export function createId(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); +} diff --git a/frontend/src/lib/query.test.ts b/frontend/src/lib/query.test.ts new file mode 100644 index 000000000..26a61970d --- /dev/null +++ b/frontend/src/lib/query.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { buildLogQuery, quoteIdentifier, timeBounds } from './query'; +import { createDemoRecords, executeDemoQuery } from './demo'; + +const now = Date.parse('2026-10-06T12:00:00Z'); +const bounds = timeBounds('15m', now + 1); +describe('query construction', () => { + it('escapes SQL identifiers, values and literal search wildcards', () => { + const sql = buildLogQuery('a"; DROP TABLE x;--', "50%_! O'Reilly", [ + { id: '1', field: 'user"name', operator: '=', value: "x' OR 1=1--" }, + ]); + expect(sql).toContain('FROM "a""; DROP TABLE x;--"'); + expect(sql).toContain('"user""name" = \'x\'\' OR 1=1--\''); + expect(sql).toContain("ILIKE '%50!%!_!! O''Reilly%' ESCAPE '!'"); + }); + it('validates and normalizes absolute ranges independently of refresh time', () => { + const range = { startTime: '2026-10-06T11:00:00Z', endTime: '2026-10-06T12:00:00Z' }; + expect(timeBounds(range, now + 3600000)).toEqual({ + startTime: '2026-10-06T11:00:00.000Z', + endTime: '2026-10-06T12:00:00.000Z', + }); + expect(() => timeBounds({ ...range, startTime: range.endTime })).toThrow('Invalid time range'); + expect(() => timeBounds({ ...range, endTime: 'not a date' })).toThrow('Invalid time range'); + }); + it('validates row limits and computes exact UTC ranges', () => { + expect(() => buildLogQuery('logs', '', [], -1)).toThrow('Limit'); + expect(() => buildLogQuery('logs', '', [], 1.5)).toThrow('Limit'); + expect(() => quoteIdentifier('')).toThrow(); + expect(timeBounds('1h', now)).toEqual({ + startTime: '2026-10-06T11:00:00.000Z', + endTime: '2026-10-06T12:00:00.000Z', + }); + }); +}); + +describe('demo query semantics', () => { + it('applies filters, literal search, descending order, limit and time bounds', () => { + const records = createDemoRecords(now); + const sql = buildLogQuery( + 'application_logs', + 'UPSTREAM', + [{ id: '1', field: 'level', operator: '=', value: 'ERROR' }], + 3, + ); + const result = executeDemoQuery({ sql, ...bounds }, records); + expect(result).toHaveLength(3); + expect(result.every((row) => row.level === 'ERROR')).toBe(true); + expect(result[0].p_timestamp).toBe(new Date(now).toISOString()); + expect( + executeDemoQuery( + { + sql, + startTime: new Date(now + 1).toISOString(), + endTime: new Date(now + 2).toISOString(), + }, + records, + ), + ).toEqual([]); + }); + it('handles wildcards and quotes as literal search characters', () => { + const records = [{ ...createDemoRecords(now)[0], message: "Paid 50%_! O'Reilly" }]; + const sql = buildLogQuery('application_logs', "50%_! O'Reilly", []); + expect(executeDemoQuery({ sql, ...bounds }, records)).toHaveLength(1); + expect( + executeDemoQuery({ sql, ...bounds }, [{ ...records[0], message: 'Paid 50 other' }]), + ).toHaveLength(0); + }); + it('rejects unsupported statements rather than silently approximating them', () => { + for (const sql of [ + 'DELETE FROM application_logs', + 'SELECT count(*) FROM application_logs', + "SELECT * FROM application_logs WHERE level = 'ERROR' OR 1=1", + 'SELECT * FROM application_logs; SELECT * FROM api_logs', + 'SELECT * FROM application_logs LIMIT 1 trailing', + "SELECT * FROM application_logs WHERE AND level = 'ERROR'", + ]) { + expect(() => executeDemoQuery({ sql, ...bounds }, createDemoRecords(now))).toThrow( + 'Demo SQL supports', + ); + } + }); +}); diff --git a/frontend/src/lib/query.ts b/frontend/src/lib/query.ts new file mode 100644 index 000000000..35595cc32 --- /dev/null +++ b/frontend/src/lib/query.ts @@ -0,0 +1,56 @@ +import type { LogFilter, TimeRange } from './types'; + +const durations: Record, number> = { + '15m': 15 * 60_000, + '1h': 60 * 60_000, + '6h': 6 * 60 * 60_000, + '24h': 24 * 60 * 60_000, + '7d': 7 * 24 * 60 * 60_000, +}; + +export function timeBounds(range: TimeRange, now: Date | number = Date.now()) { + if (typeof range === 'object' && range !== null) { + const start = Date.parse(range.startTime); + const end = Date.parse(range.endTime); + if (!Number.isFinite(start) || !Number.isFinite(end) || start >= end) + throw new Error('Invalid time range'); + return { startTime: new Date(start).toISOString(), endTime: new Date(end).toISOString() }; + } + const end = Number(now); + if (!Number.isFinite(end) || !(range in durations)) throw new Error('Invalid time range'); + return { + startTime: new Date(end - durations[range]).toISOString(), + endTime: new Date(end).toISOString(), + }; +} + +export function quoteIdentifier(value: string): string { + if (!value || value.includes('\0')) + throw new Error('A field or dataset name must be nonempty and contain no null characters'); + return `"${value.replaceAll('"', '""')}"`; +} + +export function quoteLiteral(value: string): string { + if (value.includes('\0')) throw new Error('Filter values cannot contain null characters'); + return `'${value.replaceAll("'", "''")}'`; +} + +/** Search is a literal case-insensitive substring of the message field. */ +export function buildLogQuery( + dataset: string, + search: string, + filters: LogFilter[], + limit = 100, +): string { + if (!Number.isInteger(limit) || limit < 1 || limit > 10_000) + throw new Error('Limit must be between 1 and 10000'); + const conditions = filters.map(({ field, operator, value }) => { + if (operator !== '=' && operator !== '!=') throw new Error('Unsupported filter operator'); + return `${quoteIdentifier(field)} ${operator} ${quoteLiteral(value)}`; + }); + if (search.trim()) { + const pattern = `%${search.trim().replaceAll('!', '!!').replaceAll('%', '!%').replaceAll('_', '!_')}%`; + conditions.push(`CAST("message" AS VARCHAR) ILIKE ${quoteLiteral(pattern)} ESCAPE '!'`); + } + return `SELECT * FROM ${quoteIdentifier(dataset)}${conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''} ORDER BY "p_timestamp" DESC LIMIT ${limit}`; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 000000000..b6ff0ab6f --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,20 @@ +export type Dataset = { name: string; type: 'logs' | 'metrics' | 'traces' }; +export type LogRecord = Record; +export type TimeRange = '15m' | '1h' | '6h' | '24h' | '7d' | { startTime: string; endTime: string }; +export type QueryRequest = { sql: string; startTime: string; endTime: string }; +export type LogFilter = { id: string; field: string; operator: '=' | '!='; value: string }; +export type SessionIdentity = { + id: string; + username: string; + method?: string; + email?: string; + source: 'server' | 'cookie' | 'demo'; +}; +export interface ParseableClient { + identity(signal?: AbortSignal): Promise; + listDatasets(signal?: AbortSignal): Promise; + schema(dataset: string, signal?: AbortSignal): Promise; + query(request: QueryRequest, signal?: AbortSignal): Promise; + login(username: string, password: string, returnPath?: string): Promise; + logout(): Promise; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 000000000..af8187b1b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import { AppProvider } from './app/AppProvider'; +import { App } from './app/App'; +import './styles/tokens.css'; +import './styles/global.css'; +import { basePath } from './lib/config'; +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/frontend/src/styles/auth-shell.css b/frontend/src/styles/auth-shell.css new file mode 100644 index 000000000..8970d4c79 --- /dev/null +++ b/frontend/src/styles/auth-shell.css @@ -0,0 +1,431 @@ +/* Measured Prism v3.2.4 shell: full-width 48px header and 192px navigation. */ +body .app-shell { + grid-template-columns: 192px minmax(0, 1fr); + padding-top: 48px; +} +body .app-shell.sidebar-collapsed { + grid-template-columns: 56px minmax(0, 1fr); +} +body .app-sidebar { + padding: 12px 8px 8px; + top: 48px; + height: calc(100dvh - 48px); + background: var(--color-bg); +} +.app-sidebar nav { + gap: 4px; +} +.app-sidebar .nav-item { + min-height: 28px; + padding: 4px 8px; + gap: 9px; + font-size: 13px; + border-radius: 5px; +} +.app-sidebar .nav-item.active { + font-weight: 500; + background: var(--color-surface-hover); +} +.app-sidebar .sidebar-group-label { + display: flex; + align-items: center; + gap: 8px; + margin: 18px 8px 8px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--color-muted); + font-size: 10px; +} +.sidebar-group-label span { + flex: 1; + height: 1px; + background: var(--color-border); +} +.sidebar-search { + position: relative; + margin-bottom: 10px; +} +.sidebar-search svg { + position: absolute; + left: 9px; + top: 7px; + color: var(--color-muted); +} +.sidebar-search input { + width: 100%; + min-width: 0; + height: 28px; + padding: 4px 8px 4px 30px; + color: var(--color-text); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 12px; +} +.sidebar-no-results { + font-size: 12px; + padding: 12px 8px; + color: var(--color-muted); +} +.app-shell .topbar { + position: fixed; + inset: 0 0 auto; + z-index: 20; + height: 48px; + min-height: 48px; + padding: 0 16px; + justify-content: flex-start; + gap: 16px; + background: var(--color-bg); +} +.app-shell .topbar > .inline { + margin-left: auto; +} +.shell-brand { + width: 23px; + height: 23px; + display: inline-flex; +} +.shell-brand svg { + width: 100%; + height: 100%; +} +#workspace-context { + min-width: 0; +} +#workspace-context select { + width: 200px; + max-width: 100%; + height: 32px; +} +.shell-identity { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.shell-username { + max-width: 170px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} +.shell-identity .avatar { + width: 28px; + height: 28px; + font-size: 12px; +} +body .sidebar-collapsed .app-sidebar { + padding-inline: 8px; +} +.sidebar-collapsed .app-sidebar .brand { + padding-inline: 9px; +} +.sidebar-collapsed .app-sidebar .workspace-label { + text-align: center; + padding-inline: 0; +} +.sidebar-collapsed .app-sidebar .nav-item { + padding-inline: 11px; +} +.auth-page { + min-height: 100dvh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: var(--color-bg); +} +.auth-card { + display: grid; + grid-template-columns: 1fr 1fr; + width: min(1152px, 100%); + min-height: 645px; + border-radius: 28px; + overflow: hidden; + background: var(--color-surface); + box-shadow: 0 24px 52px #18182724; +} +.auth-intro { + display: flex; + flex-direction: column; + padding: 38px 48px 46px; + background-color: var(--color-primary-soft); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28'%3E%3Cpath d='M11 14h6m-3-3v6' stroke='%238a83de' stroke-opacity='.16'/%3E%3C/svg%3E"); +} +.auth-brand { + display: flex; + align-items: center; + gap: 7px; + font-size: 32px; + font-weight: 750; + letter-spacing: -1px; + margin-bottom: 33px; + width: fit-content; +} +.auth-brand svg { + width: 25px; + height: 25px; +} +.auth-intro h1 { + font-size: 36px; + line-height: 1.22; + font-weight: 600; + letter-spacing: -0.8px; +} +.auth-intro h1 span { + color: var(--color-primary); +} +.auth-tagline { + margin-top: 22px; + font-size: 16px; + color: var(--color-muted); +} +.auth-benefits { + list-style: none; + padding: 0; + margin: 40px 0 0; + display: grid; + gap: 16px; +} +.auth-benefits li { + display: flex; + align-items: center; + gap: 12px; + font-size: 16px; + line-height: 1.35; +} +.auth-benefits svg { + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 10px; + color: #008b91; + background: var(--color-surface); + border-radius: 12px; + box-shadow: 0 2px 2px #18183f18; +} +.auth-benefits li:nth-child(2) svg { + color: #8542e5; +} +.auth-benefits li:nth-child(3) svg { + color: #078937; +} +.auth-benefits li:nth-child(4) svg { + color: #a86500; +} +.auth-benefits li:nth-child(5) svg { + color: #6554c5; +} +.auth-footer { + color: var(--color-muted); + font-size: 12px; + margin-top: auto; + padding-top: 40px; +} +.auth-content { + padding: 48px 64px; + display: flex; + flex-direction: column; +} +.auth-content h2 { + font-size: 20px; + line-height: 1.4; + font-weight: 600; + text-align: center; +} +.auth-content > .login-form { + margin-top: 62px; +} +.auth-description { + margin-top: 14px; + color: var(--color-muted); + font-size: 12px; +} +.auth-description + .login-form { + margin-top: 24px; +} +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} +.login-form .ui-input { + height: 40px; +} +.login-form > .ui-button { + min-height: 40px; +} +.password-field { + position: relative; +} +.password-field input { + padding-right: 44px; +} +.password-field .password-visibility { + position: absolute; + right: 4px; + bottom: 3px; + width: 34px; + height: 34px; +} +.login-form .forgot-password { + align-self: flex-start; + padding: 0; + min-height: 20px; + height: auto; + font-size: 14px; + color: var(--color-primary); +} +.login-divider { + display: flex; + align-items: center; + gap: 16px; + font-size: 11px; + color: var(--color-muted); + margin: 16px 0; +} +.login-divider::before, +.login-divider::after { + content: ''; + height: 1px; + flex: 1; + background: var(--color-border); +} +.auth-demo, +.auth-help { + margin-top: auto; + padding-top: 40px; + text-align: center; + font-size: 13px; + color: var(--color-muted); +} +.auth-demo.ui-button { + padding-block: 12px; + margin-top: 40px; +} +.auth-native-link { + justify-content: center; + margin-top: 14px; +} +.connection-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 20px; +} +@media (max-width: 1100px) { + .auth-content, + .auth-intro { + padding: 40px 32px; + } + .auth-benefits li { + font-size: 14px; + } + .shell-username { + max-width: 110px; + } +} +@media (max-width: 800px) { + body .app-shell { + grid-template-columns: 56px minmax(0, 1fr); + } + body .app-sidebar { + padding-inline: 8px; + } + .app-sidebar .nav-item > span, + .sidebar-search, + .sidebar-group-label { + display: none !important; + } + .app-sidebar .nav-item { + padding-inline: 11px; + } + .app-shell .topbar { + padding: 0 16px; + } + .shell-username { + display: none; + } + .sidebar-footer button { + font-size: 0; + gap: 0; + padding-inline: 11px; + } + .auth-content, + .auth-intro { + padding: 32px 24px; + } + .auth-intro h1 { + font-size: 30px; + } + .auth-benefits { + gap: 20px; + } +} +@media (max-width: 640px) { + .auth-page { + padding: 24px 16px; + } + .auth-card { + grid-template-columns: 1fr; + min-height: auto; + border-radius: 20px; + } + .auth-intro { + padding: 24px; + } + .auth-brand { + font-size: 26px; + margin-bottom: 20px; + } + .auth-intro h1 { + font-size: 27px; + } + .auth-intro h1 br { + display: none; + } + .auth-intro h1 span::before { + content: ' '; + } + .auth-tagline { + font-size: 14px; + margin-top: 10px; + } + .auth-benefits, + .auth-footer { + display: none; + } + .auth-content { + padding: 28px 24px; + } + .auth-content > .login-form { + margin-top: 28px; + } + .auth-help { + padding-top: 28px; + } + .app-shell .topbar { + padding-inline: 10px; + gap: 8px; + } + #workspace-context select { + width: 110px; + } + .app-shell .topbar > .inline { + gap: 3px; + } + .app-shell .topbar > .inline > .ui-badge { + max-width: 60px; + font-size: 9px; + padding-inline: 5px; + } + .shell-brand { + display: none; + } + .topbar .breadcrumbs > span, + .topbar .breadcrumbs > svg { + display: none; + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 000000000..862c82213 --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,1358 @@ +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--color-bg); + color: var(--color-text); + font: 13px/1.5 var(--font-sans); + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-decoration: none; +} +button, +input, +select, +textarea { + font: inherit; +} +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: transparent; +} +button svg { + flex-shrink: 0; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1 { + font-size: 25px; + letter-spacing: -0.7px; + line-height: 1.3; + font-weight: 650; +} +h2 { + font-size: 14px; + font-weight: 650; +} +code, +.mono { + font-family: var(--font-mono); + font-size: 12px; +} +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 3px; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.skip-link { + position: fixed; + top: -100px; + left: 12px; + z-index: 100; + padding: 10px 18px; + color: white; + background: #3a3a8c; +} +.skip-link:focus { + top: 10px; +} +.app-shell { + display: grid; + grid-template-columns: 224px minmax(0, 1fr); + min-height: 100dvh; +} +.app-shell.sidebar-collapsed { + grid-template-columns: 68px minmax(0, 1fr); +} +.app-sidebar { + background: var(--color-surface); + border-right: 1px solid var(--color-border); + display: flex; + flex-direction: column; + padding: 22px 12px 12px; + position: sticky; + top: 0; + height: 100dvh; +} +.brand { + display: flex; + align-items: center; + gap: 8px; + padding: 0 9px 28px; + font-size: 24px; + font-weight: 750; + letter-spacing: -0.8px; +} +.brand-mark { + color: white; + background: #3a3a8c; + height: 29px; + width: 29px; + border-radius: 8px; + font-size: 26px; + line-height: 26px; + text-align: center; + font-weight: 600; + position: relative; + flex-shrink: 0; +} +.brand-mark span { + font-size: 17px; + position: absolute; + top: -7px; + right: 3px; + color: #c0c0ff; +} +.brand-dot { + color: var(--color-primary); +} +.workspace-label { + color: var(--color-muted); + font-size: 9px; + font-weight: 650; + letter-spacing: 1px; + padding: 0 10px 14px; + white-space: nowrap; +} +.app-sidebar nav { + display: flex; + flex-direction: column; + gap: 5px; +} +.nav-item { + display: flex; + align-items: center; + gap: 11px; + padding: 11px 12px; + min-height: 42px; + border-radius: 6px; + color: var(--color-muted); + font-size: 13px; + font-weight: 500; +} +.nav-item:hover { + background: var(--color-surface-hover); + color: var(--color-text); +} +.nav-item.active { + color: var(--color-primary); + background: var(--color-primary-soft); +} +.sidebar-footer { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 7px; + margin-top: auto; + padding-top: 30px; +} +.sidebar-footer button { + justify-content: flex-start; + font-size: 12px; +} +.workspace-status { + display: flex; + align-items: center; + gap: 7px; + padding: 10px 3px; + font-size: 10px; +} +.status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #81818f; +} +.status-dot.demo { + background: #bc7b20; +} +.workspace-status .ui-badge { + margin-left: auto; + font-size: 8px; +} +.app-body { + min-width: 0; +} +.topbar { + height: 62px; + padding: 0 28px; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} +.breadcrumbs { + display: flex; + gap: 12px; + align-items: center; + font-size: 12px; + color: var(--color-muted); +} +.breadcrumbs strong { + color: var(--color-text); + font-weight: 500; +} +.avatar { + width: 27px; + height: 27px; + border-radius: 50%; + display: grid; + place-items: center; + background: var(--color-primary-soft); + color: var(--color-primary); + font-size: 11px; + font-weight: 700; + margin-left: 8px; +} +.inline { + display: flex; + align-items: center; + gap: 9px; +} +.wrap { + flex-wrap: wrap; +} +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} +.muted { + color: var(--color-muted); + font-size: 12px; +} +.demo-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + min-height: 35px; + padding: 2px 28px; + background: var(--color-primary-soft); + color: var(--color-primary); + font-size: 11px; +} +.demo-banner button { + font-size: 11px; +} +.page { + padding: 30px 28px; + max-width: 1800px; + margin: 0 auto; +} +.page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 26px; +} +.page-heading p { + margin-top: 7px; + font-size: 12px; + color: var(--color-muted); +} +.dataset-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-bottom: 17px; +} +.dataset-toolbar select { + min-width: 180px; +} +.text-link { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--color-primary); + font-weight: 500; +} +.text-link:hover { + text-decoration: underline; +} +.explorer-layout { + display: grid; + grid-template-columns: 196px minmax(0, 1fr); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 8px; + min-height: 590px; + overflow: hidden; +} +.explorer-layout.fields-hidden { + grid-template-columns: minmax(0, 1fr); +} +.field-sidebar { + border-right: 1px solid var(--color-border); + padding: 16px 12px; + min-width: 0; +} +.field-sidebar > .panel-heading { + padding: 0 2px 15px; +} +.field-sidebar input { + width: 100%; + font-size: 11px; +} +.eyebrow { + color: var(--color-muted); + font-size: 9px; + letter-spacing: 0.9px; + font-weight: 600; + margin: 24px 6px 8px; +} +.field-node { + width: 100%; + justify-content: flex-start !important; + padding: 7px 5px !important; + font-size: 11px !important; + min-height: 33px !important; + height: auto !important; + color: var(--color-muted) !important; +} +.field-node > span:not(.field-check) { + overflow: hidden; + text-overflow: ellipsis; +} +.field-node[aria-pressed='true'] { + color: var(--color-primary) !important; +} +.field-check { + margin-left: auto; +} +.count-label { + background: var(--color-surface-hover); + padding: 1px 5px; + border-radius: 4px; + font-size: 10px; + color: var(--color-muted); + margin-left: 4px; +} +.explorer-content { + min-width: 0; +} +.explorer-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 12px; + border-bottom: 1px solid var(--color-border); +} +.filter-bar { + display: flex; + gap: 9px; + flex: 1; + min-width: 200px; +} +.search-field { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + position: relative; +} +.search-field > svg { + position: absolute; + z-index: 1; + left: 11px; + color: var(--color-muted); + pointer-events: none; +} +.search-field input { + padding-left: 34px; + width: 100%; +} +.search-field .ui-field { + width: 100%; +} +.filter-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + width: 100%; +} +.filter-chip { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + padding: 0 4px 0 9px; + border: 1px solid var(--color-border); + border-radius: 5px; + background: var(--color-primary-soft); +} +.filter-chip button { + width: 24px; + min-height: 24px; + height: 24px; +} +.panel-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 16px 18px; +} +.histogram { + padding: 0 18px; +} +.histogram .panel-heading { + padding: 18px 0; +} +.histogram h2 { + font-size: 12px; +} +.histogram-plot { + height: 102px; + display: flex; + gap: 4px; + align-items: flex-end; + position: relative; +} +.chart-grid { + position: absolute; + inset: 0; + background: repeating-linear-gradient( + to top, + var(--color-border) 0, + var(--color-border) 1px, + transparent 1px, + transparent 33% + ); + opacity: 0.6; +} +.histogram-column { + position: relative; + flex: 1; + height: 100%; + display: flex; + align-items: flex-end; +} +.histogram-bar { + background: #7775c9; + width: 100%; + border-radius: 2px 2px 0 0; + opacity: 0.85; +} +.histogram-column:hover .histogram-bar { + background: #3a3a8c; + opacity: 1; +} +.chart-axis { + display: flex; + justify-content: space-between; + font-size: 9px; + color: var(--color-muted); + margin: 9px 0 21px; +} +.result-heading { + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + padding: 12px 18px; +} +.results-table { + min-width: 0; +} +.table-scroll { + overflow: auto; + width: 100%; +} +table { + border-collapse: collapse; + text-align: left; + width: 100%; + white-space: nowrap; +} +th { + background: var(--color-bg); + color: var(--color-muted); + font-size: 10px; + font-weight: 550; + padding: 10px 12px; + border-bottom: 1px solid var(--color-border); +} +td { + padding: 9px 12px; + border-bottom: 1px solid var(--color-border); + max-width: 480px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; +} +td.mono { + font-size: 10px; +} +td:first-child:has(button) { + padding: 2px 3px; + width: 28px; +} +tr:hover td { + background: var(--color-surface-hover); +} +.message-cell { + min-width: 260px; +} +.table-footer { + padding: 12px 16px; + color: var(--color-muted); + font-size: 10px; + display: flex; + justify-content: space-between; + gap: 12px; +} +.loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 160px; + color: var(--color-muted); +} +.field-values { + margin: 0; +} +.field-values > div { + border-bottom: 1px solid var(--color-border); + padding: 13px 0; +} +.field-values dt { + font-size: 11px; + color: var(--color-muted); + margin-bottom: 5px; +} +.field-values dd { + margin: 0; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; +} +.field-values code { + overflow-wrap: anywhere; + white-space: pre-wrap; + font-size: 12px; +} +.error-text { + color: var(--color-danger, #b42318); + font-size: 12px; +} +.editor-card { + overflow: hidden; + padding: 0 !important; +} +.sql-editor { + border-top: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); +} +.cm-editor.cm-focused { + outline: 2px solid var(--color-primary); + outline-offset: -2px; +} +.editor-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + gap: 16px; +} +.query-results { + margin-top: 22px; + overflow: hidden; + padding: 0 !important; +} +.list-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 18px; + margin-bottom: 20px; +} +.list-toolbar .search-field { + max-width: 340px; +} +.dataset-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); + gap: 18px; +} +.dataset-card { + padding: 23px !important; +} +.dataset-card > .inline { + justify-content: space-between; +} +.dataset-card h2 { + margin: 20px 0 6px; + overflow-wrap: anywhere; +} +.dataset-card-footer { + display: flex; + justify-content: space-between; + align-items: center; + border-top: 1px solid var(--color-border); + padding-top: 18px; + margin-top: 23px; +} +.icon-tile { + display: grid; + place-items: center; + width: 37px; + height: 37px; + background: var(--color-primary-soft); + color: var(--color-primary); + border-radius: 8px; + flex-shrink: 0; +} +.schema-list { + margin: 8px 0 25px; +} +.schema-list > div { + padding: 12px 0; + display: flex; + justify-content: space-between; + border-bottom: 1px solid var(--color-border); +} +.notice { + margin: 20px 0; + padding: 13px 16px; + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-muted); + background: var(--color-surface); + font-size: 12px; +} +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); + gap: 20px; +} +.dashboard-tile { + padding: 0 !important; + overflow: hidden; +} +.dashboard-footer { + border-top: 1px solid var(--color-border); + padding: 13px 18px; + display: flex; + justify-content: space-between; + gap: 12px; +} +.library-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; +} +.library-grid > .ui-card { + padding: 24px; +} +.library-grid h2 { + margin-bottom: 12px; +} +.library-grid p { + margin-bottom: 20px; +} +.welcome-card { + background: linear-gradient( + 110deg, + var(--color-surface) 20%, + var(--color-primary-soft) + ) !important; + display: flex; + justify-content: space-between; + padding: 32px !important; + gap: 30px; + overflow: hidden; +} +.welcome-card h2 { + font-size: 29px; + font-weight: 550; + letter-spacing: -0.8px; + margin: 16px 0 8px; +} +.welcome-card p { + max-width: 400px; + color: var(--color-muted); + font-size: 13px; +} +.primary-link { + display: inline-flex; + align-items: center; + gap: 20px; + margin-top: 25px; + background: #3a3a8c; + color: white; + border-radius: 5px; + padding: 10px 14px; + font-size: 12px; +} +.welcome-art { + display: flex; + align-items: center; + gap: 6px; + min-width: 260px; + padding-right: 20px; + transform: skewY(-12deg); +} +.welcome-art span { + width: 8px; + background: #7775c9; + opacity: 0.35; + border-radius: 3px; +} +.overview-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 18px; + margin: 22px 0 32px; +} +.overview-stats > .ui-card { + display: grid; + grid-template-columns: 1fr auto; + padding: 22px; + gap: 12px; +} +.overview-stats svg { + color: var(--color-primary); + grid-row: 1/3; + grid-column: 2; +} +.overview-stats strong { + font-size: 23px; + font-weight: 550; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} +.overview-dataset { + padding: 19px; + display: flex; + align-items: center; + gap: 14px; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-surface); + min-width: 0; +} +.overview-dataset:hover { + border-color: var(--color-primary); +} +.overview-dataset strong { + font-size: 12px; + overflow-wrap: anywhere; +} +.overview-dataset p { + font-size: 10px; + color: var(--color-muted); + margin-top: 4px; +} +.overview-dataset > svg { + margin-left: auto; + flex-shrink: 0; + color: var(--color-muted); +} +@media (min-width: 1600px) { + .page { + padding: 36px 42px; + } +} +@media (max-width: 1100px) { + .app-shell { + grid-template-columns: 190px minmax(0, 1fr); + } + .explorer-layout { + grid-template-columns: 160px minmax(0, 1fr); + } + .page { + padding: 24px 20px; + } + .page-heading { + align-items: flex-start; + flex-wrap: wrap; + } + .welcome-art { + min-width: 180px; + padding: 0; + gap: 4px; + } + .brand { + font-size: 22px; + } +} +@media (max-width: 760px) { + .app-shell, + .app-shell.sidebar-collapsed { + grid-template-columns: 60px minmax(0, 1fr); + } + .app-sidebar { + padding: 18px 6px 8px; + } + .brand { + padding: 0 8px 25px; + } + .brand > span, + .workspace-label, + .nav-item > span, + .workspace-status { + display: none; + } + .nav-item { + padding: 12px 14px; + } + .sidebar-footer button { + font-size: 0; + gap: 0; + padding: 10px 14px; + } + .sidebar-footer button svg { + width: 18px; + } + .sidebar-footer [data-sidebar='trigger'] { + display: none; + } + .topbar { + padding: 0 14px; + height: 54px; + } + .breadcrumbs { + gap: 6px; + font-size: 10px; + } + .breadcrumbs > span, + .breadcrumbs > svg, + .avatar { + display: none; + } + .page { + padding: 22px 14px; + } + h1 { + font-size: 22px; + } + .page-heading { + gap: 15px; + margin-bottom: 22px; + } + .page-heading > .inline { + width: 100%; + flex-wrap: wrap; + } + .demo-banner { + padding: 8px 14px; + font-size: 9px; + flex-wrap: wrap; + gap: 2px; + } + .demo-banner button { + padding: 0; + min-height: 24px; + } + .dataset-toolbar { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + .dataset-toolbar .inline { + flex-wrap: wrap; + gap: 6px; + } + .dataset-toolbar select { + min-width: 100px; + max-width: 210px; + } + .explorer-layout { + display: flex; + flex-direction: column; + } + .field-sidebar { + border-right: 0; + border-bottom: 1px solid var(--color-border); + max-height: 190px; + overflow-y: auto; + } + .field-sidebar > .panel-heading { + padding-bottom: 8px; + } + .field-sidebar .eyebrow { + margin-top: 12px; + } + .filter-bar { + flex-wrap: wrap; + min-width: 0; + } + .filter-bar .search-field { + flex-basis: 100%; + } + .explorer-content { + width: 100%; + } + .histogram-plot { + gap: 2px; + } + .histogram { + padding: 0 12px; + } + .chart-axis { + font-size: 8px; + } + .chart-axis span:nth-child(2) { + display: none; + } + .panel-heading { + padding: 12px; + } + .panel-heading .muted { + font-size: 10px; + } + .table-footer, + .editor-footer { + flex-wrap: wrap; + } + .library-grid, + .overview-stats { + grid-template-columns: 1fr; + } + .welcome-card { + padding: 24px !important; + } + .welcome-card h2 { + font-size: 25px; + } + .welcome-art { + display: none; + } + .section-heading { + gap: 10px; + flex-wrap: wrap; + } + .dashboard-footer { + flex-wrap: wrap; + } + .time-range { + gap: 5px; + } +} +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + +/* Compact explorer surfaces mapped to the recovered Prism component boundaries. */ +.page { + padding: 20px; +} +.page-heading { + margin-bottom: 18px; +} +.page-heading h1 { + font-size: 20px; + font-weight: 600; + letter-spacing: -0.35px; +} +.logs-page .page-heading { + margin-bottom: 12px; +} +.explorer-layout { + border-radius: 6px; + grid-template-columns: 208px minmax(0, 1fr); +} +.dataset-toolbar { + padding-bottom: 12px; +} +.field-sidebar { + padding: 12px; +} +.explorer-toolbar { + padding: 10px 12px; +} +.filter-chip .filter-chip-edit { + width: auto; + border: 0; + background: none; + color: inherit; + cursor: pointer; + padding: 3px 0; + font: inherit; + text-align: left; +} +.results-actions { + justify-content: flex-end; + gap: 5px; +} +.result-heading { + flex-wrap: wrap; + padding: 8px 12px; +} +.result-heading h2 { + font-size: 12px; +} +.results-actions .ui-button { + font-size: 11px; +} +.column-sort { + border: 0; + background: none; + padding: 0; + display: flex; + align-items: center; + gap: 8px; + color: inherit; + font: inherit; + cursor: pointer; +} +.column-sort svg { + opacity: 0.6; +} +.rows-wrapped td { + white-space: pre-wrap; + overflow-wrap: anywhere; + min-width: 130px; +} +.rows-wrapped .message-cell { + min-width: 220px; +} +.column-options { + display: grid; + gap: 10px; + max-height: 320px; + overflow: auto; + margin: 16px 0; +} +.column-options label { + display: flex; + align-items: center; + gap: 10px; + overflow-wrap: anywhere; +} +.column-options input { + accent-color: var(--color-primary); +} +.dialog-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.event-json { + padding: 14px; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 5px; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px/1.6 var(--font-mono); +} +.field-actions { + flex-shrink: 0; + gap: 0; +} +.field-actions .ui-button { + width: 28px; + height: 28px; + min-height: 28px; +} +.field-values dd > code { + min-width: 0; +} +.sql-layout { + display: grid; + grid-template-columns: 210px minmax(0, 1fr); + border: 1px solid var(--color-border); + border-radius: 6px; + overflow: hidden; + background: var(--color-surface); +} +.sql-datasets { + border-right: 1px solid var(--color-border); + padding: 12px; + min-width: 0; +} +.sql-datasets .panel-heading { + padding: 0 0 14px; +} +.sql-datasets .ui-input { + width: 100%; +} +.sql-dataset { + width: 100%; + overflow: hidden; + justify-content: flex-start !important; + font-size: 11px !important; + text-align: left; + margin-top: 6px; +} +.sql-main { + min-width: 0; +} +.sql-main .ui-card { + border: 0; + border-radius: 0; +} +.sql-main .query-results { + margin-top: 0; + border-top: 1px solid var(--color-border); +} +.sql-main .panel-heading { + padding: 10px 14px; + flex-wrap: wrap; +} +.sql-main .editor-footer { + padding: 10px 14px; +} +.dataset-table { + padding: 0 !important; + overflow: hidden; +} +.dataset-table td { + padding: 12px 16px; +} +.dataset-table th { + padding: 10px 16px; +} +.dataset-name { + display: inline-flex; + gap: 9px; + align-items: center; + font-weight: 500; +} +.dataset-name svg { + color: var(--color-muted); + flex-shrink: 0; +} +.dataset-name:hover { + color: var(--color-primary); +} +@media (max-width: 760px) { + .page { + padding: 16px 12px; + } + .sql-layout { + grid-template-columns: minmax(0, 1fr); + } + .sql-datasets { + border-right: 0; + border-bottom: 1px solid var(--color-border); + max-height: 180px; + overflow: auto; + } + .time-range { + max-width: 100%; + } + .time-range select { + min-width: 0; + } + .results-actions { + justify-content: flex-start; + } + .dataset-table td { + max-width: 220px; + } +} + +/* Authenticated v3.2.4 reference: full-height fields and a 48px explorer toolbar. */ +.logs-page.page { + padding: 0; + max-width: none; +} +.logs-page .explorer-layout { + border: 0; + border-radius: 0; + grid-template-columns: 240px minmax(0, 1fr); + min-height: calc(100dvh - 48px); +} +.logs-page .explorer-layout.fields-hidden { + grid-template-columns: minmax(0, 1fr); +} +.logs-page .field-sidebar { + padding: 12px 16px; +} +.field-sidebar .field-group-heading { + margin: 12px -16px 6px; + padding: 6px 16px; + background: var(--color-bg); +} +.field-group-heading h2 { + font-size: 12px; + font-weight: 400; +} +.field-node { + min-height: 28px !important; + font-family: var(--font-mono); +} +.logs-page .explorer-toolbar { + height: 48px; + padding: 7px 16px; + flex-wrap: nowrap; +} +.explorer-time-controls { + margin-left: auto; +} +.explorer-filters { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; + padding: 12px 16px 4px; +} +.sql-handoff { + height: 28px; + width: 28px; + display: grid; + place-items: center; + border: 1px solid var(--color-border); + border-radius: 5px; +} +.logs-page .histogram { + padding: 0 16px; +} +.logs-page .histogram .panel-heading { + padding: 12px 0 8px; +} +.logs-page .histogram-plot { + height: 82px; +} +.logs-page .chart-axis { + margin: 7px 0 12px; +} +.histogram-bar { + background: #3d80f5; + border-radius: 2px 2px 0 0; + opacity: 1; +} +.histogram-column:hover .histogram-bar { + background: #2668d9; +} +.logs-page .result-heading { + min-height: 48px; + border-top: 0; + padding: 7px 16px; +} +.logs-page .result-heading h2 { + font-weight: 400; + color: var(--color-muted); +} +.logs-page th { + height: 36px; + background: var(--color-surface); + font-size: 12px; + font-weight: 400; +} +.logs-page td { + height: 36px; + padding-block: 6px; +} +.logs-page td.mono { + font-size: 12px; +} +.logs-page td.log-data-cell { + max-width: 600px; + width: 100%; +} +.log-data-pair { + margin-right: 5px; +} +.log-data-key { + color: var(--color-primary); +} +.sql-page.page { + padding: 0; + max-width: none; +} +.sql-query-tab { + height: 40px; + padding: 10px 16px; + border-bottom: 1px solid var(--color-border); + font-size: 12px; + background: var(--color-surface); +} +.sql-page > .page-heading { + height: 52px; + padding: 8px 16px; + margin: 0; + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} +.sql-page > .page-heading h1 { + font-size: 14px; +} +.sql-page .sql-layout { + grid-template-columns: 272px minmax(0, 1fr); + min-height: calc(100dvh - 140px); + border: 0; + border-radius: 0; +} +.sql-page .sql-datasets { + padding: 10px 16px; +} +.sql-page .sql-main { + padding: 9px; + background: var(--color-surface-hover); +} +.sql-page .editor-card { + border: 1px solid var(--color-border); + border-radius: 7px 7px 0 0; +} +.sql-page .editor-card > .panel-heading { + display: none; +} +.sql-page .query-results { + min-height: 380px; + border: 1px solid var(--color-border); + border-radius: 0 0 7px 7px; +} +.sql-page .editor-footer { + flex-direction: row-reverse; + justify-content: flex-end; +} +@media (max-width: 760px) { + .logs-page .explorer-layout { + display: flex; + } + .logs-page .explorer-toolbar { + padding-inline: 8px; + } + .explorer-filters { + padding-inline: 10px; + } + .logs-page .field-sidebar { + max-height: 180px; + } + .sql-page .sql-layout { + grid-template-columns: minmax(0, 1fr); + } + .sql-page > .page-heading { + height: auto; + min-height: 52px; + } +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 000000000..4e82c5dfb --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,61 @@ +:root { + color-scheme: light; + --font-sans: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', 'SFMono-Regular', Consolas, monospace; + --color-bg: #f7f8fa; + --color-surface: #ffffff; + --color-surface-hover: #f1f2f6; + --color-surface-raised: #ffffff; + --color-border: #e3e5ec; + --color-border-strong: #c7cbd7; + --color-text: #252735; + --color-muted: #656a7d; + --color-primary: #3a3a8c; + --color-primary-hover: #2e2e72; + --color-primary-soft: #ededfa; + --color-on-primary: #ffffff; + --color-success: #147b54; + --color-success-soft: #eaf7ef; + --color-warning: #946010; + --color-warning-soft: #fff5de; + --color-danger: #c03840; + --color-danger-soft: #fff0f1; + --color-focus: #6464c6; + --color-overlay: rgb(19 22 37 / 42%); + --radius-sm: 5px; + --radius-md: 8px; + --radius-lg: 12px; + --shadow-panel: 0 12px 48px rgb(20 25 50 / 15%); + --bg: var(--color-bg); + --surface: var(--color-surface); + --text: var(--color-text); + --muted: var(--color-muted); + --border: var(--color-border); + --primary: var(--color-primary); +} + +[data-theme='dark'] { + color-scheme: dark; + --color-bg: #15161e; + --color-surface: #1d1f2a; + --color-surface-hover: #272a38; + --color-surface-raised: #232633; + --color-border: #343747; + --color-border-strong: #53586e; + --color-text: #edeef5; + --color-muted: #a6abc0; + --color-primary: #a7a7ef; + --color-primary-hover: #bdbdfa; + --color-primary-soft: #30304d; + --color-on-primary: #17172b; + --color-success: #79d8ab; + --color-success-soft: #193c30; + --color-warning: #f1c576; + --color-warning-soft: #40351f; + --color-danger: #ff969d; + --color-danger-soft: #48282e; + --color-focus: #a7a7ef; + --color-overlay: rgb(0 0 0 / 60%); + --shadow-panel: 0 12px 48px rgb(0 0 0 / 35%); +} diff --git a/frontend/story-tests/components.spec.ts b/frontend/story-tests/components.spec.ts new file mode 100644 index 000000000..0e03fdd8e --- /dev/null +++ b/frontend/story-tests/components.spec.ts @@ -0,0 +1,116 @@ +import { test, expect, type Page } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; + +async function story(page: Page, name: string) { + await page.goto(`/iframe.html?id=prism-component-library--${name}&viewMode=story`); + await expect(page.locator('#storybook-root')).not.toBeEmpty(); +} + +test('button variants render with names and disabled semantics', async ({ page }) => { + await story(page, 'buttons'); + await expect(page.getByRole('button')).toHaveCount(7); + await expect(page.getByRole('button', { name: 'Create dataset', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Unavailable', exact: true })).toBeDisabled(); + await expect(page.getByRole('button', { name: 'Refresh results', exact: true })).toBeVisible(); +}); + +test('input labels, help and errors associate with their controls', async ({ page }) => { + await story(page, 'inputs'); + const input = page.getByRole('textbox', { name: 'Dataset name', exact: true }); + await input.fill('production_events'); + await expect(input).toHaveValue('production_events'); + await expect(input).toHaveAccessibleDescription('Use a name your team can recognize.'); + const invalid = page.getByRole('textbox', { name: 'Query name', exact: true }); + await expect(invalid).toHaveAttribute('aria-invalid', 'true'); + await expect(invalid).toHaveAccessibleDescription('Enter a name to save this query.'); + await expect(page.getByRole('alert')).toHaveText('Enter a name to save this query.'); + await expect(page.getByRole('textbox', { name: 'Managed value', exact: true })).toHaveAttribute( + 'readonly', + '', + ); + await page.getByRole('combobox', { name: 'Time range' }).selectOption('1h'); + await expect(page.getByRole('combobox', { name: 'Time range' })).toHaveValue('1h'); +}); + +test('status badges and empty/loading stories render meaningful text', async ({ page }) => { + await story(page, 'badges'); + for (const label of ['Logs', 'Healthy', 'Warning', 'Error']) { + await expect(page.getByText(label, { exact: true })).toBeVisible(); + } + await story(page, 'empty'); + await expect(page.getByRole('heading', { name: 'No events match your filters' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Clear filters' })).toBeVisible(); + await story(page, 'loading'); + await expect(page.getByRole('status')).toHaveText('Loading events'); +}); + +test('modal contains keyboard focus and Escape restores its opener', async ({ page }) => { + await story(page, 'modal'); + const opener = page.getByRole('button', { name: 'Save view', exact: true }); + await opener.focus(); + await page.keyboard.press('Enter'); + const dialog = page.getByRole('dialog', { name: 'Save view', exact: true }); + await expect(dialog).toBeVisible(); + await expect(dialog).toHaveAccessibleDescription( + 'Keep these filters and columns for your next investigation.', + ); + await dialog.getByRole('textbox', { name: 'View name' }).fill('Production errors'); + for (let index = 0; index < 8; index++) { + await page.keyboard.press(index < 4 ? 'Tab' : 'Shift+Tab'); + expect(await dialog.evaluate((element) => element.contains(document.activeElement))).toBe(true); + } + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(opener).toBeFocused(); +}); + +test('detail sheet exposes its title and record, then restores focus', async ({ page }) => { + await story(page, 'detail-sheet'); + const opener = page.getByRole('button', { name: 'Inspect record' }); + await opener.click(); + const sheet = page.getByRole('dialog', { name: 'Record details' }); + await expect(sheet).toBeVisible(); + await expect(sheet).toContainText('Request completed'); + await sheet.getByRole('button', { name: 'Close dialog' }).click(); + await expect(sheet).toHaveCount(0); + await expect(opener).toBeFocused(); +}); + +test('tabs select panels with arrow keys', async ({ page }) => { + await story(page, 'tab-views'); + await expect(page.getByRole('tablist', { name: 'Result format' })).toBeVisible(); + const table = page.getByRole('tab', { name: 'Table', exact: true }); + await table.focus(); + await expect(table).toHaveAttribute('aria-selected', 'true'); + await page.keyboard.press('ArrowRight'); + await expect(page.getByRole('tab', { name: 'JSON', exact: true })).toBeFocused(); + await expect(page.getByRole('tab', { name: 'JSON', exact: true })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await expect(page.getByRole('tabpanel')).toHaveText('Raw event fields appear here.'); + await page.keyboard.press('ArrowLeft'); + await expect(page.getByRole('tabpanel')).toHaveText('Events appear in a table.'); +}); + +for (const name of [ + 'buttons', + 'inputs', + 'badges', + 'modal', + 'detail-sheet', + 'empty', + 'tab-views', + 'loading', +]) { + test(`WCAG accessibility smoke: ${name}`, async ({ page }) => { + await story(page, name); + if (name === 'modal') + await page.getByRole('button', { name: 'Save view', exact: true }).click(); + if (name === 'detail-sheet') await page.getByRole('button', { name: 'Inspect record' }).click(); + const result = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect(result.violations).toEqual([]); + }); +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 000000000..17e2ebaf9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client", "node"] + }, + "include": [ + "src", + "vite.config.ts", + "playwright.config.ts", + ".storybook", + "e2e", + "playwright.live.config.ts", + "e2e-live" + ] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 000000000..7955d23d6 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig, loadEnv } from 'vite'; +import react from '@vitejs/plugin-react'; +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + return { + plugins: [react()], + server: { + port: 5173, + strictPort: true, + proxy: { + '/api': { + target: env.PARSEABLE_PROXY_TARGET || 'http://127.0.0.1:8000', + changeOrigin: false, + }, + }, + }, + }; +}); diff --git a/src/handlers/http/modal/mod.rs b/src/handlers/http/modal/mod.rs index 2cc1ef70e..492fe99ea 100644 --- a/src/handlers/http/modal/mod.rs +++ b/src/handlers/http/modal/mod.rs @@ -63,6 +63,7 @@ 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/query_server.rs b/src/handlers/http/modal/query_server.rs index 2b4d05acc..05fff2e21 100644 --- a/src/handlers/http/modal/query_server.rs +++ b/src/handlers/http/modal/query_server.rs @@ -89,7 +89,7 @@ impl ParseableServer for QueryServer { .service(Server::get_traces_webscope()) .service(Server::get_dataset_stats_webscope()), ) - .service(Server::get_generated()); + .configure(Server::configure_ui); } async fn load_metadata(&self) -> anyhow::Result> { diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index 71b3ba813..12e67c670 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -73,8 +73,8 @@ use crate::{ // use super::generate; use super::ParseableServer; -use super::generate; use super::load_on_init; +use super::{generate, generate_next}; pub struct Server; @@ -123,7 +123,7 @@ impl ParseableServer for Server { .service(Self::get_ingest_otel_factory().wrap(from_fn( resource_check::check_resource_utilization_middleware, ))) - .service(Self::get_generated()); + .configure(Self::configure_ui); } async fn load_metadata(&self) -> anyhow::Result> { @@ -831,7 +831,67 @@ impl Server { } // GET "/" ==> Serve the static frontend directory - pub fn get_generated() -> ResourceFiles { - ResourceFiles::new("/", generate()).resolve_not_found_to_root() + // GET "/next" ==> Serve the second frontend, when one was embedded at build time + pub fn configure_ui(config: &mut web::ServiceConfig) { + Self::mount_ui(config, generate(), generate_next()); + } + + /// Registers `/next` ahead of the catch-all `/` so each UI owns its own routes. + fn mount_ui(config: &mut web::ServiceConfig, primary: UiFiles, next: UiFiles) { + if !next.is_empty() { + config.service(ResourceFiles::new("/next", next).resolve_not_found_to_root()); + } + config.service(ResourceFiles::new("/", primary).resolve_not_found_to_root()); + } +} + +type UiFiles = std::collections::HashMap<&'static str, static_files::Resource>; + +#[cfg(test)] +mod tests { + use actix_web::{App, body::to_bytes, test}; + + use super::*; + + fn ui(index: &'static [u8]) -> UiFiles { + UiFiles::from([( + "index.html", + static_files::Resource { + data: index, + modified: 0, + mime_type: "text/html", + }, + )]) + } + + async fn body_at(path: &str, next: UiFiles) -> Bytes { + let app = test::init_service( + App::new().configure(|config| Server::mount_ui(config, ui(b"classic"), next)), + ) + .await; + let response = + test::call_service(&app, test::TestRequest::get().uri(path).to_request()).await; + assert!(response.status().is_success(), "GET {path} failed"); + to_bytes(response.into_body()).await.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"); } } diff --git a/src/handlers/http/oidc.rs b/src/handlers/http/oidc.rs index 94753d7cb..f95d807c9 100644 --- a/src/handlers/http/oidc.rs +++ b/src/handlers/http/oidc.rs @@ -426,7 +426,13 @@ pub fn redirect_to_client( } fn redirect_no_oauth_setup(mut url: Url) -> HttpResponse { - url.set_path("oidc-not-configured"); + // The side-by-side frontend under `/next` has its own explanation page. + let next_ui = url.path() == "/next" || url.path().starts_with("/next/"); + url.set_path(if next_ui { + "next/oidc-not-configured" + } else { + "oidc-not-configured" + }); let mut response = HttpResponse::MovedPermanently(); response.insert_header((actix_web::http::header::LOCATION, url.as_str())); response.insert_header((actix_web::http::header::CACHE_CONTROL, "no-store")); @@ -721,3 +727,46 @@ fn is_valid_redirect_url(base_url: &str, redirect_url: &str) -> bool { .map(|url| url.origin() == redirect_origin) .unwrap_or(false) } + +#[cfg(test)] +mod tests { + use super::*; + + fn location(from: &str) -> String { + let response = redirect_no_oauth_setup(Url::parse(from).unwrap()); + response + .headers() + .get(actix_web::http::header::LOCATION) + .unwrap() + .to_str() + .unwrap() + .to_owned() + } + + #[test] + fn no_oauth_redirect_keeps_next_ui_requests_on_next() { + assert_eq!( + location("http://host/next"), + "http://host/next/oidc-not-configured" + ); + assert_eq!( + location("http://host/next/logs?x=1"), + "http://host/next/oidc-not-configured?x=1" + ); + } + + #[test] + fn no_oauth_redirect_sends_everything_else_to_the_classic_page() { + for from in [ + "http://host/", + "http://host/nextfoo", + "http://host/logs/next", + ] { + assert_eq!( + location(from), + "http://host/oidc-not-configured", + "redirect from {from}" + ); + } + } +}