diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml new file mode 100644 index 00000000..d4a3d006 --- /dev/null +++ b/.github/workflows/deploy-pages.yml @@ -0,0 +1,83 @@ +name: Deploy EVTX Viewer to GitHub Pages + +on: + push: + branches: [ wasm-viewer ] + # Allow manual trigger + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # ---------------------- + # Install build tooling + # ---------------------- + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Set up Rust toolchain (stable) with wasm target + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + target: wasm32-unknown-unknown + override: true + + - name: Install & cache wasm-pack + uses: jetli/wasm-pack-action@v0.4.0 + with: + version: v0.13.1 + + # ---------------------- + # Build the WASM crate + # ---------------------- + - name: Build evtx-wasm crate (release) + run: | + wasm-pack build evtx-wasm \ + --target web \ + --out-dir evtx-viewer/src/wasm \ + --out-name evtx_wasm \ + --release + + # ---------------------- + # Build the React viewer + # ---------------------- + - name: Install JS/TS dependencies + working-directory: evtx-wasm/evtx-viewer + run: bun install --frozen-lockfile + + # Copy built-in sample to public folder before build + - name: Add sample EVTX to viewer public folder + run: | + mkdir -p evtx-wasm/evtx-viewer/public/samples + cp samples/security.evtx evtx-wasm/evtx-viewer/public/samples/ + + - name: Build viewer + working-directory: evtx-wasm/evtx-viewer + run: bun run build + + # ---------------------- + # Upload artifact for Pages + # ---------------------- + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: evtx-wasm/evtx-viewer/dist + + deploy: + needs: build + runs-on: ubuntu-latest + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 0575dde3..bc809b3f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ bazel-* result* repomix-output.txt + +# WASM artefacts generated at runtime – do not commit +evtx-wasm/evtx-viewer/public/pkg +# Samples are being copied by build scripts before deploying +**/public/samples/ diff --git a/evtx-wasm/.cargo/config.toml b/evtx-wasm/.cargo/config.toml new file mode 100644 index 00000000..0e465b2d --- /dev/null +++ b/evtx-wasm/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ["--cfg", "getrandom_backend=\"wasm_js\""] diff --git a/evtx-wasm/.cursor/rules/evtx-state-architecture.mdc b/evtx-wasm/.cursor/rules/evtx-state-architecture.mdc new file mode 100644 index 00000000..b2afed11 --- /dev/null +++ b/evtx-wasm/.cursor/rules/evtx-state-architecture.mdc @@ -0,0 +1,46 @@ +description: +globs: +alwaysApply: true +--- +# EVTX Viewer – State Architecture Guide + +This project uses a **single global React reducer store** defined in +[`state/rootReducer.ts`](mdc:evtx-wasm/evtx-viewer/src/state/rootReducer.ts) and +provided via [`state/store.tsx`](mdc:evtx-wasm/evtx-viewer/src/state/store.tsx). + +## Global store (authoritative app-wide data) +* **Slices live under** `state//` – e.g. + * `filters/filtersSlice.ts` + * `columns/columnsSlice.ts` + * `ingest/ingestSlice.ts` +* `rootReducer.ts` combines slices; `globalInitialState` holds defaults. +* `GlobalProvider` (exported from `store.tsx`) wraps the React tree and exposes: + * `useGlobalState(selector)` – read‐only selector + * `useGlobalDispatch()` – dispatch actions + * Convenience hooks `useFiltersState`, `useColumnsState`, `useIngestState`. +* **Put data here if** it is shared by multiple top-level features or must be + persisted / serialised (e.g. filters, columns, ingest progress). + +## Feature-local state & selectors +* Hooks that only concern a *single* UI feature stay next to that component, + not in `state/`. + * Examples in *FilterSidebar*: + * [`useFacetCounts.ts`](mdc:evtx-wasm/evtx-viewer/src/components/FilterSidebar/useFacetCounts.ts) + * [`useActiveFilterChips.ts`](mdc:evtx-wasm/evtx-viewer/src/components/FilterSidebar/useActiveFilterChips.ts) +* These hooks pull global slices via the selector helpers, do derivations, and + return UI-ready data. No other feature should import them. +* **Put logic here if** it is purely presentational or scoped to one feature + (collapse toggles, local search terms, derived chip arrays, etc.). + +## Legacy wrapper – `AppStateProvider` +`AppStateProvider` now simply: +1. Wraps the tree in `GlobalProvider`. +2. Seeds initial filters / columns once on mount. +3. Exposes compatibility hooks (`useAppState`, `useEvtxState`) until all code + migrates. + +When adding new state: +1. Ask “is this referenced by more than one feature?” If *yes* → new slice. +2. Otherwise co-locate the hook with the feature directory. + +This rule helps future threads recognise where to place or fetch stateful logic. diff --git a/evtx-wasm/.cursor/rules/wasm-bindings.mdc b/evtx-wasm/.cursor/rules/wasm-bindings.mdc new file mode 100644 index 00000000..312c6ebf --- /dev/null +++ b/evtx-wasm/.cursor/rules/wasm-bindings.mdc @@ -0,0 +1,94 @@ +--- +description: when working on wasm bindings, or importing wasm bindings in TS. +alwaysApply: false +--- +# WASM Bindings & Regeneration Guide + +This project uses `wasm-pack` to compile the Rust crate under +[`evtx-wasm/`](mdc:evtx-wasm/src/lib.rs) into the viewer. The output lives in +[`src/wasm/`](mdc:evtx-wasm/evtx-viewer/src/wasm/) and contains: + +* `evtx_wasm.js` – JS glue +* `evtx_wasm_bg.wasm` – compiled WebAssembly +* `evtx_wasm.d.ts` – **typings used by TypeScript** + +Whenever you add or rename a function in the Rust `#[wasm_bindgen]` interface +(e.g. `parse_chunk_records`) you **MUST** regenerate the pkg so the `.d.ts` +reflects the change; otherwise TS won’t see the method. + +## Regeneration steps +```bash +# from repo root +cd evtx-wasm # workspace with Cargo.toml +wasm-pack build --target web --release \ + --out-dir ../evtx-wasm/evtx-viewer/src/wasm +``` +The helper script [`evtx-wasm/evtx-viewer/setup-dev.sh`](mdc:evtx-wasm/evtx-viewer/setup-dev.sh) +performs a similar copy using symlinks during dev. + +## Importing in TS +Use a **type-only** import for static typing and a dynamic import for runtime: +```ts +// type side +type WasmBindings = typeof import('../wasm/evtx_wasm'); + +// runtime side (inside an async function) +const wasm: WasmBindings = await import('../wasm/evtx_wasm.js') as WasmBindings; +const parser = new wasm.EvtxWasmParser(bytes); +``` +Do **NOT** cast to `any`; rely on the generated typings. If TypeScript complains +that a new method doesn’t exist, first verify you rebuilt the pkg and that +`evtx_wasm.d.ts` lists the method. + +## Linting / ESLint +The generated `*_bg.wasm.d.ts` contains disable directives ESLint trips over. +We ignore those globally via `globalIgnores` in `eslint.config.js`: +```js +globalIgnores(['**/*_bg.wasm.d.ts']); +``` +Never edit generated files; update the Rust code and rebuild instead. +# WASM Bindings & Regeneration Guide + +This project uses `wasm-pack` to compile the Rust crate under +[`evtx-wasm/`](mdc:evtx-wasm/src/lib.rs) into the viewer. The output lives in +[`src/wasm/`](mdc:evtx-wasm/evtx-viewer/src/wasm/) and contains: + +* `evtx_wasm.js` – JS glue +* `evtx_wasm_bg.wasm` – compiled WebAssembly +* `evtx_wasm.d.ts` – **typings used by TypeScript** + +Whenever you add or rename a function in the Rust `#[wasm_bindgen]` interface +(e.g. `parse_chunk_records`) you **MUST** regenerate the pkg so the `.d.ts` +reflects the change; otherwise TS won’t see the method. + +## Regeneration steps +```bash +# from repo root +cd evtx-wasm # workspace with Cargo.toml +wasm-pack build --target web --release \ + --out-dir ../evtx-wasm/evtx-viewer/src/wasm +``` +The helper script [`evtx-wasm/evtx-viewer/setup-dev.sh`](mdc:evtx-wasm/evtx-viewer/setup-dev.sh) +performs a similar copy using symlinks during dev. + +## Importing in TS +Use a **type-only** import for static typing and a dynamic import for runtime: +```ts +// type side +type WasmBindings = typeof import('../wasm/evtx_wasm'); + +// runtime side (inside an async function) +const wasm: WasmBindings = await import('../wasm/evtx_wasm.js') as WasmBindings; +const parser = new wasm.EvtxWasmParser(bytes); +``` +Do **NOT** cast to `any`; rely on the generated typings. If TypeScript complains +that a new method doesn’t exist, first verify you rebuilt the pkg and that +`evtx_wasm.d.ts` lists the method. + +## Linting / ESLint +The generated `*_bg.wasm.d.ts` contains disable directives ESLint trips over. +We ignore those globally via `globalIgnores` in `eslint.config.js`: +```js +globalIgnores(['**/*_bg.wasm.d.ts']); +``` +Never edit generated files; update the Rust code and rebuild instead. diff --git a/evtx-wasm/.gitignore b/evtx-wasm/.gitignore new file mode 100644 index 00000000..6c577811 --- /dev/null +++ b/evtx-wasm/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +public/pkg/ +public/assets/*.js +public/assets/*.js.map + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Temp files +*.tmp +*.temp + +# WASM build artifacts +target/ +Cargo.lock \ No newline at end of file diff --git a/evtx-wasm/Cargo.toml b/evtx-wasm/Cargo.toml new file mode 100644 index 00000000..95cd1734 --- /dev/null +++ b/evtx-wasm/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "evtx-wasm" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +evtx = { path = "..", default-features = false, features = [] } +wasm-bindgen = "0.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde-wasm-bindgen = "0.6" +web-sys = { version = "0.3", features = ["console"] } +js-sys = "0.3" +console_error_panic_hook = "0.1" +arrow2 = { version = "0.18", features = ["io_ipc"] } +getrandom = { version = "0.3", features = ["wasm_js"] } + +[profile.release] +opt-level = "z" +lto = true diff --git a/evtx-wasm/README.md b/evtx-wasm/README.md new file mode 100644 index 00000000..416aa359 --- /dev/null +++ b/evtx-wasm/README.md @@ -0,0 +1,138 @@ +# EVTX WASM Parser Explorer + +A modern web-based EVTX (Windows Event Log) file parser and explorer built with Rust, WebAssembly, TypeScript, and Bun. + +## Features + +- 🚀 **Fast Performance**: Native performance with WebAssembly +- 📁 **Drag-and-drop**: Easy file loading with drag-and-drop interface +- 🔍 **Search**: Real-time search through parsed records +- 📊 **Chunk Navigation**: Browse through EVTX chunks efficiently +- 💾 **Export**: Export parsed records as JSON +- 🎨 **Modern UI**: Clean, responsive interface with dark mode support +- 🔒 **Privacy**: All processing happens in your browser +- 🔥 **Hot Reload**: Development mode with automatic reloading + +## Tech Stack + +- **Backend**: Bun native server with TypeScript +- **Frontend**: TypeScript with modern ES modules +- **Parser**: Rust compiled to WebAssembly +- **Styling**: Modern CSS with CSS variables +- **Build**: Bun for blazing fast builds + +## Prerequisites + +- [Bun](https://bun.sh) (latest version) +- [Rust](https://rustup.rs/) with `wasm-pack` +- [wasm-pack](https://rustwasm.github.io/wasm-pack/installer/) + +## Installation + +1. Clone the repository +2. Install dependencies: + ```bash + bun install + ``` + +3. Install wasm-pack if you haven't already: + ```bash + cargo install wasm-pack + ``` + +## Development + +Run the development server with hot reload: + +```bash +bun run dev +``` + +The server will start at `http://localhost:3000` with automatic reloading on file changes. + +## Building + +Build for development: +```bash +bun run build +``` + +Build for production: +```bash +bun run build:prod +``` + +## Production + +Start the production server: +```bash +NODE_ENV=production bun run start +``` + +## Usage + +1. **Load File**: Drag an EVTX file onto the drop zone or click to browse +2. **View File Info**: See file metadata including chunk count and status +3. **Browse Chunks**: Click on any chunk to select it +4. **Parse Records**: + - "Parse All Records" - Parse up to 1000 records from all chunks + - "Parse Selected Chunk" - Parse only the selected chunk +5. **Search**: Use the search box to filter records in real-time +6. **View Details**: Click on any record to see the full JSON structure +7. **Export**: Click "Export JSON" to download the parsed records + +## Architecture + +``` +evtx-wasm/ +├── src/ +│ ├── lib.rs # Rust WASM bindings +│ ├── server.ts # Bun server with routing +│ └── app.ts # Frontend TypeScript application +├── public/ +│ ├── index.html # Main HTML file +│ ├── assets/ # CSS and compiled JS +│ └── pkg/ # WASM build output +├── Cargo.toml # Rust dependencies +├── package.json # Node dependencies +└── tsconfig.json # TypeScript configuration +``` + +## Performance + +- **Zero-copy file transfers** using Bun's native file serving +- **Streaming support** for large files +- **Efficient chunk-based parsing** to handle large EVTX files +- **Client-side processing** - files never leave your machine +- **Optimized WASM** with size optimization (`opt-level = "z"`) + +## Configuration + +Server configuration via environment variables: +- `PORT` - Server port (default: 3000) +- `NODE_ENV` - Environment mode (development/production) + +## Scripts + +- `bun run dev` - Start development server with hot reload +- `bun run build` - Build for development +- `bun run build:prod` - Build for production +- `bun run start` - Start production server +- `bun run clean` - Clean build artifacts + +## Browser Support + +- Chrome/Edge 88+ +- Firefox 89+ +- Safari 15+ +- Any browser with WebAssembly and ES modules support + +## Limitations + +- Limited to parsing 1000 records at once for UI performance +- Large EVTX files may take time to load initially +- Browser memory constraints apply to very large files + +## License + +MIT/Apache-2.0 (same as the parent evtx crate) \ No newline at end of file diff --git a/evtx-wasm/evtx-viewer/.gitignore b/evtx-wasm/evtx-viewer/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/evtx-wasm/evtx-viewer/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/evtx-wasm/evtx-viewer/.vite/deps/_metadata.json b/evtx-wasm/evtx-viewer/.vite/deps/_metadata.json new file mode 100644 index 00000000..3813188c --- /dev/null +++ b/evtx-wasm/evtx-viewer/.vite/deps/_metadata.json @@ -0,0 +1,8 @@ +{ + "hash": "cb1640f7", + "configHash": "1a3010c0", + "lockfileHash": "e3b0c442", + "browserHash": "eb26b4e2", + "optimized": {}, + "chunks": {} +} \ No newline at end of file diff --git a/evtx-wasm/evtx-viewer/.vite/deps/package.json b/evtx-wasm/evtx-viewer/.vite/deps/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/evtx-wasm/evtx-viewer/.vite/deps/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/evtx-wasm/evtx-viewer/README.md b/evtx-wasm/evtx-viewer/README.md new file mode 100644 index 00000000..7959ce42 --- /dev/null +++ b/evtx-wasm/evtx-viewer/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/evtx-wasm/evtx-viewer/bun.lock b/evtx-wasm/evtx-viewer/bun.lock new file mode 100644 index 00000000..7e043259 --- /dev/null +++ b/evtx-wasm/evtx-viewer/bun.lock @@ -0,0 +1,832 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "evtx-viewer", + "dependencies": { + "@duckdb/duckdb-wasm": "^1.29.1-dev132.0", + "@fluentui/react-icons": "^2.0.305", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", + "@types/styled-components": "^5.1.34", + "apache-arrow": "^21.0.0", + "idb": "^7.1.1", + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "styled-components": "^6.1.19", + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "fake-indexeddb": "^6.0.1", + "globals": "^16.2.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0", + "vitest": "^1.5.0", + }, + }, + }, + "packages": { + "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.27.7", "", {}, "sha512-xgu/ySj2mTiUFmdE9yCMfBxLp4DHd5DwmbbD05YAuICfodYT3VvRxbrh81LGQ/8UpSdtMdfKMn3KouYDX59DGQ=="], + + "@babel/core": ["@babel/core@7.27.7", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w=="], + + "@babel/generator": ["@babel/generator@7.27.5", "", { "dependencies": { "@babel/parser": "^7.27.5", "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^3.0.2" } }, "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.27.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.27.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.27.6", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.27.6" } }, "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug=="], + + "@babel/parser": ["@babel/parser@7.27.7", "", { "dependencies": { "@babel/types": "^7.27.7" }, "bin": "./bin/babel-parser.js" }, "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/runtime": ["@babel/runtime@7.27.6", "", {}, "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.27.7", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", "@babel/types": "^7.27.7", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw=="], + + "@babel/types": ["@babel/types@7.27.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw=="], + + "@duckdb/duckdb-wasm": ["@duckdb/duckdb-wasm@1.29.1-dev132.0", "", { "dependencies": { "apache-arrow": "^17.0.0" } }, "sha512-OUkJuH9564GQ/OgggdgJ/Yxmlk5PYnWiJe0rrNkEs0Sfsi00nK6WZgJmWGGAtCK6le2KJxFWZ4Z2MjUKGBzLuQ=="], + + "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], + + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.2.2", "", { "dependencies": { "@emotion/memoize": "^0.8.1" } }, "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw=="], + + "@emotion/memoize": ["@emotion/memoize@0.8.1", "", {}, "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="], + + "@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.5", "", { "os": "android", "cpu": "arm" }, "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.5", "", { "os": "android", "cpu": "arm64" }, "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.5", "", { "os": "android", "cpu": "x64" }, "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.5", "", { "os": "linux", "cpu": "arm" }, "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.5", "", { "os": "linux", "cpu": "none" }, "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.5", "", { "os": "none", "cpu": "x64" }, "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.5", "", { "os": "win32", "cpu": "x64" }, "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.30.0", "", {}, "sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="], + + "@fluentui/react-icons": ["@fluentui/react-icons@2.0.305", "", { "dependencies": { "@griffel/react": "^1.0.0", "tslib": "^2.1.0" }, "peerDependencies": { "react": ">=16.8.0 <19.0.0" } }, "sha512-lxJZsW4IKaPaIrlaZlvDFujztKwWXSR3tUMBUBG0WtEGoQkbrWhrt8fqzhQ9BEbq02FtifLFUpaIqiJ326//Rw=="], + + "@griffel/core": ["@griffel/core@1.19.2", "", { "dependencies": { "@emotion/hash": "^0.9.0", "@griffel/style-types": "^1.3.0", "csstype": "^3.1.3", "rtl-css-js": "^1.16.1", "stylis": "^4.2.0", "tslib": "^2.1.0" } }, "sha512-WkB/QQkjy9dE4vrNYGhQvRRUHFkYVOuaznVOMNTDT4pS9aTJ9XPrMTXXlkpcwaf0D3vNKoerj4zAwnU2lBzbOg=="], + + "@griffel/react": ["@griffel/react@1.5.30", "", { "dependencies": { "@griffel/core": "^1.19.2", "tslib": "^2.1.0" }, "peerDependencies": { "react": ">=16.8.0 <20.0.0" } }, "sha512-1q4ojbEVFY5YA0j1NamP0WWF4BKh+GHsVugltDYeEgEaVbH3odJ7tJabuhQgY+7Nhka0pyEFWSiHJev0K3FSew=="], + + "@griffel/style-types": ["@griffel/style-types@1.3.0", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-bHwD3sUE84Xwv4dH011gOKe1jul77M1S6ZFN9Tnq8pvZ48UMdY//vtES6fv7GRS5wXYT4iqxQPBluAiYAfkpmw=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.8", "", { "dependencies": { "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/set-array": ["@jridgewell/set-array@1.2.1", "", {}, "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.0", "", {}, "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.19", "", {}, "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.1", "", { "os": "android", "cpu": "arm" }, "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.44.1", "", { "os": "android", "cpu": "arm64" }, "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.44.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.44.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.44.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.44.1", "", { "os": "linux", "cpu": "arm" }, "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.44.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.44.1", "", { "os": "linux", "cpu": "none" }, "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.44.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.1", "", { "os": "linux", "cpu": "x64" }, "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.1", "", { "os": "linux", "cpu": "x64" }, "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.44.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.1", "", { "os": "win32", "cpu": "x64" }, "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], + + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.12", "", { "dependencies": { "@tanstack/virtual-core": "3.13.12" }, "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" } }, "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.12", "", {}, "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], + + "@types/command-line-args": ["@types/command-line-args@5.2.3", "", {}, "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw=="], + + "@types/command-line-usage": ["@types/command-line-usage@5.0.4", "", {}, "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hoist-non-react-statics": ["@types/hoist-non-react-statics@3.3.6", "", { "dependencies": { "@types/react": "*", "hoist-non-react-statics": "^3.3.0" } }, "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@20.19.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-OP+We5WV8Xnbuvw0zC2m4qfB/BJvjyCwtNjhHdJxV1639SGSKrLmJkc3fMnp2Qy8nJyHp8RO6umxELN/dS1/EA=="], + + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + + "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + + "@types/styled-components": ["@types/styled-components@5.1.34", "", { "dependencies": { "@types/hoist-non-react-statics": "*", "@types/react": "*", "csstype": "^3.0.2" } }, "sha512-mmiVvwpYklFIv9E8qfxuPyIt/OuyIrn6gMOAMOFUO3WJfSrSE+sGUoa4PiZj77Ut7bKZpaa6o1fBKS/4TOEvnA=="], + + "@types/stylis": ["@types/stylis@4.2.5", "", {}, "sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.35.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/type-utils": "8.35.0", "@typescript-eslint/utils": "8.35.0", "@typescript-eslint/visitor-keys": "8.35.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.35.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.35.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", "@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.35.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.35.0", "@typescript-eslint/types": "^8.35.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.35.0", "", { "dependencies": { "@typescript-eslint/types": "8.35.0", "@typescript-eslint/visitor-keys": "8.35.0" } }, "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.35.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.35.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.35.0", "@typescript-eslint/utils": "8.35.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.35.0", "", {}, "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.35.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.35.0", "@typescript-eslint/tsconfig-utils": "8.35.0", "@typescript-eslint/types": "8.35.0", "@typescript-eslint/visitor-keys": "8.35.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.35.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", "@typescript-eslint/typescript-estree": "8.35.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.35.0", "", { "dependencies": { "@typescript-eslint/types": "8.35.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@4.6.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.19", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" } }, "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ=="], + + "@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="], + + "@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="], + + "@vitest/snapshot": ["@vitest/snapshot@1.6.1", "", { "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", "pretty-format": "^29.7.0" } }, "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ=="], + + "@vitest/spy": ["@vitest/spy@1.6.1", "", { "dependencies": { "tinyspy": "^2.2.0" } }, "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw=="], + + "@vitest/utils": ["@vitest/utils@1.6.1", "", { "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" } }, "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "acorn-walk": ["acorn-walk@8.3.4", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "apache-arrow": ["apache-arrow@21.0.0", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^24.0.3", "command-line-args": "^6.0.1", "command-line-usage": "^7.0.1", "flatbuffers": "^25.1.24", "json-bignum": "^0.0.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.js" } }, "sha512-UueXr0y7S6SB6ToIEON0ZIwRln1EY05NIMXKfPu8fumASypkXXHEb6LRTZGh7vnYoQ9TgqNMNN1937wyY9lyFQ=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-back": ["array-back@6.2.2", "", {}, "sha512-gUAZ7HPyb4SJczXAMUXMGAvI976JoK3qEx9v1FTmeYuJj0IBiaKttG1ydtGKdkfqWkIkouke7nG8ufGy77+Cvw=="], + + "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001726", "", {}, "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw=="], + + "chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chalk-template": ["chalk-template@0.4.0", "", { "dependencies": { "chalk": "^4.1.2" } }, "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg=="], + + "check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "command-line-args": ["command-line-args@6.0.1", "", { "dependencies": { "array-back": "^6.2.2", "find-replace": "^5.0.2", "lodash.camelcase": "^4.3.0", "typical": "^7.2.0" }, "peerDependencies": { "@75lb/nature": "latest" }, "optionalPeers": ["@75lb/nature"] }, "sha512-Jr3eByUjqyK0qd8W0SGFW1nZwqCaNCtbXjRo2cRJC1OYxWl3MZ5t1US3jq+cO4sPavqgw4l9BMGX0CBe+trepg=="], + + "command-line-usage": ["command-line-usage@7.0.3", "", { "dependencies": { "array-back": "^6.2.2", "chalk-template": "^0.4.0", "table-layout": "^4.1.0", "typical": "^7.1.1" } }, "sha512-PqMLy5+YGwhMh1wS04mVG44oqDsgyLRSKJBdOo1bnYhMKBW65gZF1dRp2OZRhiTjgUHljy99qkO7bsctLaw35Q=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.177", "", {}, "sha512-7EH2G59nLsEMj97fpDuvVcYi6lwTcM1xuWw3PssD8xzboAW7zj7iB3COEEEATUfjLHrs5uKBLQT03V/8URx06g=="], + + "esbuild": ["esbuild@0.25.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.5", "@esbuild/android-arm": "0.25.5", "@esbuild/android-arm64": "0.25.5", "@esbuild/android-x64": "0.25.5", "@esbuild/darwin-arm64": "0.25.5", "@esbuild/darwin-x64": "0.25.5", "@esbuild/freebsd-arm64": "0.25.5", "@esbuild/freebsd-x64": "0.25.5", "@esbuild/linux-arm": "0.25.5", "@esbuild/linux-arm64": "0.25.5", "@esbuild/linux-ia32": "0.25.5", "@esbuild/linux-loong64": "0.25.5", "@esbuild/linux-mips64el": "0.25.5", "@esbuild/linux-ppc64": "0.25.5", "@esbuild/linux-riscv64": "0.25.5", "@esbuild/linux-s390x": "0.25.5", "@esbuild/linux-x64": "0.25.5", "@esbuild/netbsd-arm64": "0.25.5", "@esbuild/netbsd-x64": "0.25.5", "@esbuild/openbsd-arm64": "0.25.5", "@esbuild/openbsd-x64": "0.25.5", "@esbuild/sunos-x64": "0.25.5", "@esbuild/win32-arm64": "0.25.5", "@esbuild/win32-ia32": "0.25.5", "@esbuild/win32-x64": "0.25.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.30.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.30.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-iN/SiPxmQu6EVkf+m1qpBxzUhE12YqFLOSySuOyVLJLEF9nzTf+h/1AJYc1JWzCnktggeNrjvQGLngDzXirU6g=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + + "fake-indexeddb": ["fake-indexeddb@6.0.1", "", {}, "sha512-He2AjQGHe46svIFq5+L2Nx/eHDTI1oKgoevBP+TthnjymXiKkeJQ3+ITeWey99Y5+2OaPFbI1qEsx/5RsGtWnQ=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-replace": ["find-replace@5.0.2", "", { "peerDependencies": { "@75lb/nature": "latest" }, "optionalPeers": ["@75lb/nature"] }, "sha512-Y45BAiE3mz2QsrN2fb5QEtO4qb44NcS7en/0y9PEVsg351HsLeVclP8QPMH79Le9sH3rs5RSwJu99W0WPZO43Q=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatbuffers": ["flatbuffers@25.2.10", "", {}, "sha512-7JlN9ZvLDG1McO3kbX0k4v+SUAg48L1rIwEvN6ZQl/eCtgJz9UylTMzE9wrmYrcorgxm3CX/3T/w5VAub99UUw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="], + + "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.2.0", "", {}, "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + + "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + + "idb": ["idb@7.1.1", "", {}, "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-bignum": ["json-bignum@0.0.3", "", {}, "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loupe": ["loupe@2.3.7", "", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], + + "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + + "react-icons": ["react-icons@5.5.0", "", { "peerDependencies": { "react": "*" } }, "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.44.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.1", "@rollup/rollup-android-arm64": "4.44.1", "@rollup/rollup-darwin-arm64": "4.44.1", "@rollup/rollup-darwin-x64": "4.44.1", "@rollup/rollup-freebsd-arm64": "4.44.1", "@rollup/rollup-freebsd-x64": "4.44.1", "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", "@rollup/rollup-linux-arm-musleabihf": "4.44.1", "@rollup/rollup-linux-arm64-gnu": "4.44.1", "@rollup/rollup-linux-arm64-musl": "4.44.1", "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-gnu": "4.44.1", "@rollup/rollup-linux-riscv64-musl": "4.44.1", "@rollup/rollup-linux-s390x-gnu": "4.44.1", "@rollup/rollup-linux-x64-gnu": "4.44.1", "@rollup/rollup-linux-x64-musl": "4.44.1", "@rollup/rollup-win32-arm64-msvc": "4.44.1", "@rollup/rollup-win32-ia32-msvc": "4.44.1", "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg=="], + + "rtl-css-js": ["rtl-css-js@1.16.1", "", { "dependencies": { "@babel/runtime": "^7.1.2" } }, "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.9.0", "", {}, "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="], + + "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "strip-literal": ["strip-literal@2.1.1", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q=="], + + "styled-components": ["styled-components@6.1.19", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA=="], + + "stylis": ["stylis@4.3.2", "", {}, "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "table-layout": ["table-layout@4.1.1", "", { "dependencies": { "array-back": "^6.2.2", "wordwrapjs": "^5.1.0" } }, "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="], + + "tinyspy": ["tinyspy@2.2.1", "", {}, "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "tslib": ["tslib@2.6.2", "", {}, "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.35.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.35.0", "@typescript-eslint/parser": "8.35.0", "@typescript-eslint/utils": "8.35.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A=="], + + "typical": ["typical@7.3.0", "", {}, "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw=="], + + "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vite": ["vite@7.0.0", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g=="], + + "vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="], + + "vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wordwrapjs": ["wordwrapjs@5.1.0", "", {}, "sha512-JNjcULU2e4KJwUNv6CHgI46UvDGitb6dGryHajXTDiLgg1/RiGoPSDw4kZfYnwGtEXf2ZMeIewDQgFGzkCB2Sg=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@1.2.1", "", {}, "sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg=="], + + "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "@babel/traverse/globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="], + + "@duckdb/duckdb-wasm/apache-arrow": ["apache-arrow@17.0.0", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^20.13.0", "command-line-args": "^5.2.1", "command-line-usage": "^7.0.1", "flatbuffers": "^24.3.25", "json-bignum": "^0.0.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.cjs" } }, "sha512-X0p7auzdnGuhYMVKYINdQssS4EcKec9TCXyez/qtJt32DrIMGbzqiaMiQ0X6fQlQpw8Fl0Qygcv4dfRAr5Gu9Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "apache-arrow/@types/node": ["@types/node@24.0.10", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ENHwaH+JIRTDIEEbDK6QSQntAYGtbvdDXnMXnZaZ6k13Du1dPMmprkEHIL7ok2Wl2aZevetwTAb5S+7yIF+enA=="], + + "apache-arrow/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + + "vite-node/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], + + "vitest/vite": ["vite@5.4.19", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA=="], + + "@duckdb/duckdb-wasm/apache-arrow/command-line-args": ["command-line-args@5.2.1", "", { "dependencies": { "array-back": "^3.1.0", "find-replace": "^3.0.0", "lodash.camelcase": "^4.3.0", "typical": "^4.0.0" } }, "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg=="], + + "@duckdb/duckdb-wasm/apache-arrow/flatbuffers": ["flatbuffers@24.12.23", "", {}, "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "apache-arrow/@types/node/undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "vite-node/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "vitest/vite/esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "@duckdb/duckdb-wasm/apache-arrow/command-line-args/array-back": ["array-back@3.1.0", "", {}, "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="], + + "@duckdb/duckdb-wasm/apache-arrow/command-line-args/find-replace": ["find-replace@3.0.0", "", { "dependencies": { "array-back": "^3.0.1" } }, "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ=="], + + "@duckdb/duckdb-wasm/apache-arrow/command-line-args/typical": ["typical@4.0.0", "", {}, "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="], + + "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vite-node/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vite-node/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vite-node/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vite-node/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vite-node/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vite-node/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vite-node/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vite-node/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vite-node/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vite-node/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vite-node/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vite-node/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vite-node/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vite-node/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vite-node/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vite-node/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vite-node/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vite-node/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vite-node/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vite-node/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vite-node/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "vitest/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "vitest/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "vitest/vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "vitest/vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "vitest/vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "vitest/vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "vitest/vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "vitest/vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "vitest/vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "vitest/vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "vitest/vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "vitest/vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "vitest/vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "vitest/vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "vitest/vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "vitest/vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "vitest/vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "vitest/vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "vitest/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "vitest/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "vitest/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + } +} diff --git a/evtx-wasm/evtx-viewer/eslint.config.js b/evtx-wasm/evtx-viewer/eslint.config.js new file mode 100644 index 00000000..cbd4e8ac --- /dev/null +++ b/evtx-wasm/evtx-viewer/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist', '**/*_bg.wasm.d.ts']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/evtx-wasm/evtx-viewer/index.html b/evtx-wasm/evtx-viewer/index.html new file mode 100644 index 00000000..4dfe3c70 --- /dev/null +++ b/evtx-wasm/evtx-viewer/index.html @@ -0,0 +1,12 @@ + + + + + + EVTX Viewer - Windows Event Log Explorer + + +
+ + + diff --git a/evtx-wasm/evtx-viewer/package.json b/evtx-wasm/evtx-viewer/package.json new file mode 100644 index 00000000..df5c604b --- /dev/null +++ b/evtx-wasm/evtx-viewer/package.json @@ -0,0 +1,42 @@ +{ + "name": "evtx-viewer", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "test": "vitest", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@duckdb/duckdb-wasm": "^1.29.1-dev132.0", + "@fluentui/react-icons": "^2.0.305", + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.12", + "@types/styled-components": "^5.1.34", + "apache-arrow": "^21.0.0", + "idb": "^7.1.1", + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-icons": "^5.5.0", + "styled-components": "^6.1.19" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^4.5.2", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "fake-indexeddb": "^6.0.1", + "globals": "^16.2.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0", + "vitest": "^1.5.0" + } +} diff --git a/evtx-wasm/evtx-viewer/setup-dev.sh b/evtx-wasm/evtx-viewer/setup-dev.sh new file mode 100755 index 00000000..d0ed988d --- /dev/null +++ b/evtx-wasm/evtx-viewer/setup-dev.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Create symlink to WASM files for development +mkdir -p public/pkg + +# Symlink sample EVTX so dev server can serve it at /samples/ +mkdir -p public/samples +ln -sf "$(cd ../../samples && pwd)/security.evtx" "$(pwd)/public/samples/security.evtx" + +# Get absolute paths +PARENT_PKG_DIR="$(cd ../public/pkg && pwd)" +CURRENT_PKG_DIR="$(pwd)/public/pkg" + +# Create symlinks with absolute paths +ln -sf "$PARENT_PKG_DIR/evtx_wasm_bg.wasm" "$CURRENT_PKG_DIR/evtx_wasm_bg.wasm" +ln -sf "$PARENT_PKG_DIR/evtx_wasm.js" "$CURRENT_PKG_DIR/evtx_wasm.js" +ln -sf "$PARENT_PKG_DIR/evtx_wasm.d.ts" "$CURRENT_PKG_DIR/evtx_wasm.d.ts" + +echo "✅ Development environment set up. WASM files linked." +echo " Source: $PARENT_PKG_DIR" +echo " Target: $CURRENT_PKG_DIR" diff --git a/evtx-wasm/evtx-viewer/src/App.tsx b/evtx-wasm/evtx-viewer/src/App.tsx new file mode 100644 index 00000000..5d04ef42 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/App.tsx @@ -0,0 +1,617 @@ +import React, { useState, useCallback, useEffect } from "react"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import styled, { useTheme } from "styled-components"; +import { useThemeMode } from "./styles/ThemeModeProvider"; +import { GlobalStyles } from "./styles/GlobalStyles"; +import { + MenuBar, + ProgressBar, + Panel, + Toolbar, + ToolbarButton, + ToolbarSeparator, + Dropdown, +} from "./components/Windows"; +import { FileTree } from "./components/FileTree"; +import { DragDropOverlay } from "./components/DragDropOverlay"; +import { StatusBar as StatusBarView } from "./components/StatusBar"; +import { + Open20Regular, + Save20Regular, + Print20Regular, + Filter20Regular, + ArrowClockwise20Regular, + Info20Regular, + ArrowExportLtr20Regular, +} from "@fluentui/react-icons"; +// Note: parsing/export handled via useEvtxLog hook; no direct EvtxParser needed here. +import { useFilters } from "./hooks/useFilters"; +import { useColumns } from "./hooks/useColumns"; +import { logger, LogLevel } from "./lib/logger"; +import init from "./wasm/evtx_wasm.js"; +import { FilterSidebar } from "./components/FilterSidebar"; +import { LogTableVirtual } from "./components/LogTableVirtual"; +import { useEvtxLog } from "./hooks/useEvtxLog"; +import { initDuckDB } from "./lib/duckdb"; +import { ColumnManager } from "./components/ColumnManager"; +import { Table20Regular as TableIcon } from "@fluentui/react-icons"; + +const AppContainer = styled.div` + display: flex; + flex-direction: column; + height: 100vh; + background: ${({ theme }) => theme.colors.background.primary}; +`; + +const MainContent = styled.div` + display: flex; + flex: 1; + overflow: hidden; +`; + +const Sidebar = styled.aside` + width: 280px; + min-width: 200px; + max-width: 400px; + border-right: 1px solid ${({ theme }) => theme.colors.border.light}; + display: flex; + flex-direction: column; +`; + +const ContentArea = styled.main` + flex: 1; + display: flex; + flex-direction: row; + overflow: hidden; +`; + +const RecordsArea = styled.div` + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +`; + +const FilterPanel = styled.aside<{ $width: number }>` + width: ${({ $width }) => $width}px; + min-width: 220px; + max-width: 400px; + display: flex; + flex-direction: column; + position: relative; +`; + +const ColumnPanel = styled.aside<{ $width: number }>` + width: ${({ $width }) => $width}px; + min-width: 220px; + max-width: 360px; + display: flex; + flex-direction: column; + position: relative; +`; + +const ColumnDivider = styled.div` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + cursor: col-resize; + background: ${({ theme }) => theme.colors.border.light}; + transition: background 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.accent.primary}; + } +`; + +const FilterDivider = styled.div` + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + cursor: col-resize; + background: ${({ theme }) => theme.colors.border.light}; + transition: background 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.accent.primary}; + } +`; + +// Local StatusBar styled components have been moved to components/StatusBar.tsx + +const LoadingOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1001; +`; + +const LoadingContent = styled.div` + background: ${({ theme }) => theme.colors.background.secondary}; + padding: ${({ theme }) => theme.spacing.xl}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + box-shadow: ${({ theme }) => theme.shadows.elevation}; + text-align: center; +`; + +function App() { + const [selectedNodeId, setSelectedNodeId] = useState(""); + const [isWasmReady, setIsWasmReady] = useState(false); + const [isDuckDbReady, setIsDuckDbReady] = useState(false); + const { filters, clearFilters } = useFilters(); + const [showFilters, setShowFilters] = useState(false); + // Table column state – start with defaults + const { columns, setColumns } = useColumns(); + const [showColumnMgr, setShowColumnMgr] = useState(false); + const [filterPanelWidth, setFilterPanelWidth] = useState(300); + const [fileTreeVersion, setFileTreeVersion] = useState(0); + const [assetProgress, setAssetProgress] = useState(0); + + const { + isLoading, + loadingMessage, + records, + matchedCount, + fileInfo, + parser, + dataSource, + currentFileId, + ingestProgress, + loadFile, + } = useEvtxLog(); + + // --- Logging level state --- + const [logLevel, setLogLevel] = useState(logger.getLogLevel()); + + const handleLogLevelChange = useCallback((level: LogLevel) => { + logger.setLogLevel(level); + setLogLevel(level); + }, []); + + const logLevelOptions = [ + { label: "DEBUG", value: LogLevel.DEBUG }, + { label: "INFO", value: LogLevel.INFO }, + { label: "WARN", value: LogLevel.WARN }, + { label: "ERROR", value: LogLevel.ERROR }, + ]; + + // Initialize WASM module + useEffect(() => { + const initEngines = async () => { + try { + logger.info("Initializing EVTX parser WASM module..."); + setAssetProgress(0.1); + await init(); + setAssetProgress(0.4); + setIsWasmReady(true); + logger.info("EVTX parser WASM module initialized"); + + // Now initialise DuckDB WASM. This step can take several seconds on + // first load because the browser has to download & compile the DB + // assets. We await it so that downstream code relying on the DB can + // safely proceed and so we can surface meaningful UI feedback. + logger.info("Initializing DuckDB WASM engine..."); + setAssetProgress(0.5); + await initDuckDB(); + setAssetProgress(1); + setIsDuckDbReady(true); + logger.info("DuckDB WASM engine ready"); + } catch (error) { + logger.error("Failed to initialise WASM engines", error); + } + }; + + void initEngines(); + }, []); + + // Handle dragging of the filter panel divider + const handleFilterDividerMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + const startX = e.clientX; + const startWidth = filterPanelWidth; + + const onMouseMove = (moveEvent: MouseEvent) => { + const deltaX = startX - moveEvent.clientX; + const newWidth = Math.max(220, Math.min(400, startWidth + deltaX)); + setFilterPanelWidth(newWidth); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, + [filterPanelWidth] + ); + + // Wrapper to gate WASM readiness and reset some App-level state before delegating + const handleFileSelect = useCallback( + async (file: File) => { + if (!isWasmReady) { + alert("WASM module is still loading. Please try again."); + return; + } + + // Reset filters in App scope on new file + clearFilters(); + + // Ingest the file (this will persist it to IndexedDB via parser) + await loadFile(file); + + // Refresh FileTree *after* the file is saved so it appears immediately + setFileTreeVersion((v) => v + 1); + }, + [isWasmReady, loadFile, clearFilters] + ); + + type TreeNodeData = { id: string; fileId?: string; logPath?: string }; + + const handleNodeSelect = useCallback( + async (node: TreeNodeData) => { + setSelectedNodeId(node.id); + logger.debug("Tree node selected", node); + + if (node.fileId) { + try { + const storage = await ( + await import("./lib/storage") + ).default.getInstance(); + const { meta, blob } = await storage.getFile(node.fileId); + // Convert Blob to File so existing parser flow works + const file = new File([blob], meta.fileName, { + type: "application/octet-stream", + }); + await handleFileSelect(file); + } catch (err) { + logger.error("Failed to load cached file", err); + alert("Could not load cached log – see console for details"); + } + return; + } + + // Handle built-in sample logs (lazy-loaded from /samples) + if (node.logPath) { + try { + const base = (import.meta.env.BASE_URL || "/").replace(/\/$/, ""); + const sampleUrl = `${base}/${node.logPath}`; + logger.info(`Fetching built-in sample log: ${sampleUrl}`); + const res = await fetch(sampleUrl); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const blob = await res.blob(); + const fileName = node.logPath.split("/").pop() ?? "sample.evtx"; + const file = new File([blob], fileName, { + type: "application/octet-stream", + }); + + await handleFileSelect(file); + } catch (err) { + logger.error("Failed to load bundled sample", err); + alert("Could not load bundled sample log. See console for details."); + } + } + }, + [handleFileSelect] + ); + + const handleRefresh = useCallback(async () => { + if (!parser || !fileInfo) return; + + // Refresh parsing via parser (Hook state will capture changes if needed) + try { + const result = await parser.parseAllRecords(); + // Currently the hook owns records; we can't set them directly here. + // For now we just log and trust DuckDB source; we may expand hook later. + logger.info("Records refreshed", { count: result.records.length }); + } catch (error) { + logger.error("Failed to refresh records", error); + } + }, [parser, fileInfo]); + + // (Effects computing matched count, bucket counts, and dataSource moved into useEvtxLog) + + const handleExport = useCallback( + async (format: "json" | "xml") => { + if (matchedCount === 0) return; + + try { + const { fetchRecords } = await import("./lib/duckdb"); + const dataArr = await fetchRecords(filters, matchedCount, 0); + const data = + parser?.exportRecords(dataArr, format) || + JSON.stringify(dataArr, null, 2); + const blob = new Blob([data], { + type: format === "json" ? "application/json" : "application/xml", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `evtx_export_${new Date().toISOString()}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + logger.info( + `Exported ${matchedCount} records as ${format.toUpperCase()}` + ); + } catch (error) { + logger.error(`Failed to export as ${format}`, error); + alert( + `Failed to export as ${format}. ${ + error instanceof Error ? error.message : "" + }` + ); + } + }, + [parser, matchedCount, filters] + ); + + const { mode: themeMode, toggle: toggleTheme } = useThemeMode(); + + const menuItems = [ + { + id: "file", + label: "File", + submenu: [ + { + id: "file-open", + label: "Open...", + icon: , + shortcut: "Ctrl+O", + onClick: () => { + document.getElementById("file-input")?.click(); + }, + }, + { + id: "file-save-as", + label: "Save Log File As...", + icon: , + shortcut: "Ctrl+S", + disabled: records.length === 0, + }, + { id: "file-sep-1", label: "sep", separator: true }, + { + id: "file-export", + label: "Export", + submenu: [ + { + id: "file-export-json", + label: "Export as JSON...", + onClick: () => handleExport("json"), + disabled: records.length === 0, + }, + { + id: "file-export-xml", + label: "Export as XML...", + onClick: () => handleExport("xml"), + disabled: records.length === 0, + }, + ], + }, + { id: "file-sep-2", label: "sep", separator: true }, + { + id: "file-print", + label: "Print...", + icon: , + shortcut: "Ctrl+P", + disabled: true, + }, + { + id: "file-exit", + label: "Exit", + shortcut: "Alt+F4", + onClick: () => window.close(), + }, + ], + }, + { + id: "view", + label: "View", + submenu: [ + { + id: "view-filter", + label: showFilters ? "Hide Filters" : "Filter Current Log", + icon: , + disabled: records.length === 0 || ingestProgress < 1, + onClick: () => setShowFilters((prev) => !prev), + }, + { + id: "view-columns", + label: showColumnMgr ? "Hide Columns" : "Manage Columns", + icon: , + disabled: !dataSource, + onClick: () => setShowColumnMgr((p) => !p), + }, + { id: "view-sep-1", label: "sep", separator: true }, + { + id: "view-refresh", + label: "Refresh", + icon: , + shortcut: "F5", + onClick: handleRefresh, + }, + { + id: "view-dark-mode", + label: + themeMode === "dark" + ? "Switch to Light Mode" + : "Switch to Dark Mode", + onClick: toggleTheme, + }, + ], + }, + { + id: "help", + label: "Help", + submenu: [ + { + id: "help-about", + label: "About EVTX Viewer", + icon: , + onClick: () => { + alert( + "EVTX Viewer v1.0.0\nA Windows Event Log viewer built with React and WebAssembly" + ); + }, + }, + ], + }, + ]; + + const currentTheme = useTheme(); + + // Determine which progress value to display in the global loading overlay. + // While the core engines are still loading we show assetProgress. + // Once they are ready but a file is being ingested we show ingestProgress. + const overlayProgress = !isDuckDbReady + ? assetProgress + : isLoading + ? ingestProgress + : undefined; + + return ( + <> + + + + + + + } + title="Open" + onClick={() => document.getElementById("file-input")?.click()} + /> + + } + title="Filter" + isActive={showFilters} + disabled={records.length === 0 || ingestProgress < 1} + onClick={() => setShowFilters((prev) => !prev)} + /> + } + title="Columns" + isActive={showColumnMgr} + disabled={!dataSource} + onClick={() => setShowColumnMgr((p) => !p)} + /> + + } + title="Refresh" + onClick={handleRefresh} + disabled={!parser} + /> + + } + title="Export" + disabled={matchedCount === 0} + onClick={() => handleExport("json")} + /> + + + + + + + + + + + + {dataSource ? ( + + ) : ( +
No data source
+ )} +
+ {showFilters && ingestProgress === 1 && ( + + + + + )} + + {showColumnMgr && ( + + e.preventDefault()} /> + setShowColumnMgr(false)} + /> + + )} +
+
+ + + + + + {/* Global loading overlay – show either during file ingest or while core WASM/DB engines are loading. */} + {(isLoading || !isDuckDbReady) && ( + + +

Loading...

+ +

+ {isLoading + ? loadingMessage + : "Downloading & compiling WASM assets..."} +

+
+
+ )} + + {/* standalone overlay removed – ColumnPanel handles sidebar */} +
+ + ); +} + +export default App; diff --git a/evtx-wasm/evtx-viewer/src/components/ColumnManager.tsx b/evtx-wasm/evtx-viewer/src/components/ColumnManager.tsx new file mode 100644 index 00000000..ee5962c8 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/ColumnManager.tsx @@ -0,0 +1,125 @@ +import React, { useMemo, useState, useEffect } from "react"; +import styled from "styled-components"; +import type { TableColumn } from "../lib/types"; +import { + Panel, + PanelBody, + SidebarHeader, + Button, + ScrollableList, + SelectableRow, +} from "./Windows"; + +// Locally override alignment so checkbox + label sit together on the left +const Row = styled(SelectableRow)` + justify-content: flex-start; +`; + +// Reuse FilterSidebar typographic scale & colors + +const SidebarBody = styled(PanelBody)` + display: flex; + flex-direction: column; + height: 100%; + padding: ${({ theme }) => theme.spacing.sm}; + background: ${({ theme }) => theme.colors.background.secondary}; +`; + +const SearchInput = styled.input` + width: 100%; + margin-bottom: ${({ theme }) => theme.spacing.sm}; + padding: 4px 6px; + background: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.caption}; + color: ${({ theme }) => theme.colors.text.primary}; + + &::placeholder { + color: ${({ theme }) => theme.colors.text.tertiary}; + } +`; + +type Props = { + allColumns: TableColumn[]; + active: TableColumn[]; + onChange: (next: TableColumn[]) => void; + onClose: () => void; + width?: number; +}; + +export const ColumnManager: React.FC = ({ + allColumns, + active, + onChange, + onClose, +}) => { + const [term, setTerm] = useState(""); + + /** + * Maintain a local superset of all columns ever seen during the component's + * lifetime. When the parent removes a column from the `active` array we + * still want it to appear (unchecked) in the list so the user can add it + * back later. We merge any *new* columns received via props on every render + * but **never** delete from this local list. + */ + const [allCols, setAllCols] = useState(allColumns); + + // Merge-in any new columns from props (e.g. dynamically added EventData cols) + useEffect(() => { + setAllCols((prev) => { + const map = new Map(prev.map((c) => [c.id, c])); + allColumns.forEach((c) => map.set(c.id, c)); + return Array.from(map.values()); + }); + }, [allColumns]); + + const activeIds = useMemo(() => new Set(active.map((c) => c.id)), [active]); + + const filtered = useMemo(() => { + const t = term.toLowerCase(); + return allCols.filter((c) => c.header.toLowerCase().includes(t)); + }, [allCols, term]); + + const toggle = (col: TableColumn) => { + if (activeIds.has(col.id)) { + onChange(active.filter((c) => c.id !== col.id)); + } else { + onChange([...active, col]); + } + }; + + return ( + + + Columns + + + + setTerm(e.target.value)} + /> + + {filtered.map((col) => ( + + toggle(col)} + /> + {col.header} + + ))} + + + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/DragDropOverlay.tsx b/evtx-wasm/evtx-viewer/src/components/DragDropOverlay.tsx new file mode 100644 index 00000000..4a8c21b9 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/DragDropOverlay.tsx @@ -0,0 +1,224 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import styled, { keyframes } from "styled-components"; +import { CloudArrowUp48Regular } from "@fluentui/react-icons"; + +const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } +`; + +const scaleIn = keyframes` + from { + transform: scale(0.8); + } + to { + transform: scale(1); + } +`; + +const Overlay = styled.div<{ $isVisible: boolean }>` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(4px); + display: ${(props) => (props.$isVisible ? "flex" : "none")}; + align-items: center; + justify-content: center; + z-index: 1000; + animation: ${fadeIn} 200ms ease-out; +`; + +const DropZone = styled.div<{ $isDragOver: boolean }>` + width: 400px; + height: 300px; + border: 3px dashed + ${({ $isDragOver, theme }) => + $isDragOver ? theme.colors.accent.primary : theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.spacing.lg}; + background: ${({ $isDragOver, theme }) => + $isDragOver + ? theme.colors.selection.background + : theme.colors.background.secondary}; + transition: all ${({ theme }) => theme.transitions.normal}; + animation: ${scaleIn} 200ms ease-out; + + &:hover { + border-color: ${({ theme }) => theme.colors.accent.primary}; + background: ${({ theme }) => theme.colors.selection.background}; + } +`; + +const IconWrapper = styled.div<{ $isDragOver: boolean }>` + color: ${({ $isDragOver, theme }) => + $isDragOver ? theme.colors.accent.primary : theme.colors.text.secondary}; + transition: all ${({ theme }) => theme.transitions.normal}; + transform: ${({ $isDragOver }) => ($isDragOver ? "scale(1.1)" : "scale(1)")}; +`; + +const Title = styled.h2` + font-size: ${({ theme }) => theme.fontSize.title}; + color: ${({ theme }) => theme.colors.text.primary}; + margin: 0; +`; + +const Subtitle = styled.p` + font-size: ${({ theme }) => theme.fontSize.body}; + color: ${({ theme }) => theme.colors.text.secondary}; + margin: 0; +`; + +const FileInput = styled.input` + display: none; +`; + +interface DragDropOverlayProps { + onFileSelect: (file: File) => void; + acceptedExtensions?: string[]; +} + +export const DragDropOverlay: React.FC = ({ + onFileSelect, + acceptedExtensions = [".evtx"], +}) => { + const [isVisible, setIsVisible] = useState(false); + const [isDragOver, setIsDragOver] = useState(false); + const dragCounter = useRef(0); + + const handleDragEnter = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) { + dragCounter.current += 1; + setIsVisible(true); + } + }, []); + + const handleDragLeave = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + dragCounter.current -= 1; + if (dragCounter.current === 0) { + setIsVisible(false); + setIsDragOver(false); + } + }, []); + + const handleDragOver = useCallback((e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + setIsVisible(false); + setIsDragOver(false); + dragCounter.current = 0; + + const files = e.dataTransfer?.files; + if (files && files.length > 0) { + const file = files[0]; + const extension = "." + file.name.split(".").pop()?.toLowerCase(); + + if (acceptedExtensions.includes(extension)) { + onFileSelect(file); + } else { + alert( + `Please select a valid file type: ${acceptedExtensions.join(", ")}` + ); + } + } + }, + [acceptedExtensions, onFileSelect] + ); + + const handleOverlayDragEnter = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + }, []); + + const handleOverlayDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Only set isDragOver to false if we're leaving the drop zone + const relatedTarget = e.relatedTarget as HTMLElement; + if (!relatedTarget || !e.currentTarget.contains(relatedTarget)) { + setIsDragOver(false); + } + }, []); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + onFileSelect(files[0]); + } + }, + [onFileSelect] + ); + + const handleClick = useCallback(() => { + const input = document.getElementById("file-input") as HTMLInputElement; + input?.click(); + }, []); + + useEffect(() => { + document.addEventListener("dragenter", handleDragEnter); + document.addEventListener("dragleave", handleDragLeave); + document.addEventListener("dragover", handleDragOver); + document.addEventListener("drop", handleDrop); + + return () => { + document.removeEventListener("dragenter", handleDragEnter); + document.removeEventListener("dragleave", handleDragLeave); + document.removeEventListener("dragover", handleDragOver); + document.removeEventListener("drop", handleDrop); + }; + }, [handleDragEnter, handleDragLeave, handleDragOver, handleDrop]); + + return ( + + e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + handleDrop(e.nativeEvent); + }} + onClick={handleClick} + > + + + + Drop EVTX file here + or click to browse + + + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/EventDetailsPane.tsx b/evtx-wasm/evtx-viewer/src/components/EventDetailsPane.tsx new file mode 100644 index 00000000..0c24d183 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/EventDetailsPane.tsx @@ -0,0 +1,363 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import type { EvtxRecord, EvtxEventData, EvtxSystemData } from "../lib/types"; +import { Table20Regular as ColumnIcon } from "@fluentui/react-icons"; +import { useFilters } from "../hooks/useFilters"; +import { useColumns } from "../hooks/useColumns"; + +const DetailsPane = styled.div<{ $height: number }>` + height: ${({ $height }) => `${$height}px`}; + border-top: 1px solid ${({ theme }) => theme.colors.border.medium}; + background: ${({ theme }) => theme.colors.background.secondary}; + padding: ${({ theme }) => theme.spacing.md}; + overflow-y: auto; +`; + +const DetailSection = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + +const DetailTitle = styled.h3` + font-size: ${({ theme }) => theme.fontSize.body}; + font-weight: 600; + margin: 0 0 ${({ theme }) => theme.spacing.sm} 0; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const DetailRow = styled.div` + display: flex; + margin-bottom: 4px; +`; + +const DetailLabel = styled.span` + font-weight: 600; + min-width: 120px; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const DetailValue = styled.span` + color: ${({ theme }) => theme.colors.text.primary}; +`; + +const DetailContent = styled.div` + font-family: ${({ theme }) => theme.fonts.mono}; + font-size: ${({ theme }) => theme.fontSize.caption}; + color: ${({ theme }) => theme.colors.text.secondary}; + white-space: pre-wrap; + word-break: break-word; +`; + +const IconBtn = styled.button<{ $variant: "include" | "exclude" }>` + width: 18px; + height: 18px; + margin-left: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + border-radius: 3px; + background: ${({ theme }) => theme.colors.background.secondary}; + font-size: 12px; + line-height: 1; + cursor: pointer; + color: ${({ theme }) => theme.colors.text.secondary}; + position: relative; /* needed for tooltip positioning */ + &:hover { + background: ${({ theme }) => theme.colors.background.hover}; + } + &:active { + background: ${({ theme }) => theme.colors.background.active}; + } + ${(props) => + props.$variant === "include" && + css` + /* could style differently if needed */ + `} + ${(props) => + props.$variant === "exclude" && + css` + /* maybe color accent */ + `} + + /* Instant tooltip using ::after */ + &[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + top: -28px; + left: 50%; + transform: translateX(-50%); + background: ${({ theme }) => theme.colors.background.secondary}; + color: ${({ theme }) => theme.colors.text.primary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + border-radius: 4px; + padding: 2px 6px; + font-size: ${({ theme }) => theme.fontSize.caption}; + white-space: nowrap; + z-index: 100; + pointer-events: none; + } +`; + +interface Props { + record: EvtxRecord; + height: number; +} + +// Utility helpers copied from the original table +const LEVEL_NAMES: Record = { + 0: "Information", + 1: "Critical", + 2: "Error", + 3: "Warning", + 4: "Information", + 5: "Verbose", +}; + +const formatDateTime = (systemTime?: string): string => { + if (!systemTime) return "-"; + try { + const date = new Date(systemTime); + return date.toLocaleString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch { + return systemTime; + } +}; + +const getSystemData = (record: EvtxRecord): EvtxSystemData => + record.Event?.System || {}; + +const getEventId = (sys: EvtxSystemData): string => { + const eid = sys.EventID; + if (typeof eid === "object" && eid !== null) { + return String((eid as Record)["#text"] ?? "-"); + } + return String(eid ?? "-"); +}; + +const getProvider = (sys: EvtxSystemData): string => + sys.Provider?.Name || sys.Provider_attributes?.Name || "-"; + +const getTimeCreated = (sys: EvtxSystemData): string => + sys.TimeCreated?.SystemTime || sys.TimeCreated_attributes?.SystemTime || ""; + +const getUserId = (sys: EvtxSystemData): string => + sys.Security?.UserID || sys.Security_attributes?.UserID || "-"; + +const renderEventData = ( + eventData: unknown, + onAdd: (k: string, v: string) => void, + onExclude: (k: string, v: string) => void, + onColumn: (k: string) => void +): React.ReactNode => { + if (!eventData) return "No event data"; + const eventObj = eventData as Record; + + // Handle EventData/Data array style + if (eventObj["Data"]) { + const rawData = eventObj["Data"] as unknown; + const dataArray = Array.isArray(rawData) ? rawData : [rawData]; + return dataArray.map((rawItem, idx) => { + const item = rawItem as Record; + const name = + (item["#attributes"] as Record | undefined)?.Name ?? + `Data${idx}`; + const value = item["#text"] ?? "-"; + const valueStr = String(value); + return ( + + {String(name)}: + {valueStr} + { + <> + onAdd(String(name), valueStr)} + > + + + + { + onExclude(String(name), valueStr)} + > + – + + } + { + onColumn(String(name))} + > + + + } + + } + + ); + }); + } + + // Generic key/value pairs + const kvRows = Object.entries(eventObj).map(([k, v]) => { + const valStr = typeof v === "object" ? JSON.stringify(v) : String(v); + return ( + + {k}: + {valStr} + { + onAdd(k, valStr)} + > + + + + } + { + onExclude(k, valStr)} + > + – + + } + { + onColumn(k)} + > + + + } + + ); + }); + + return kvRows.length > 0 ? kvRows : JSON.stringify(eventData, null, 2); +}; + +export const EventDetailsPane: React.FC = ({ record, height }) => { + const { setFilters } = useFilters(); + const { addColumn } = useColumns(); + + const handleInclude = React.useCallback( + (field: string, value: string) => { + setFilters((prev) => { + const prevField = prev.eventData?.[field] ?? []; + if (prevField.includes(value)) return prev; + return { + ...prev, + eventData: { + ...(prev.eventData ?? {}), + [field]: [...prevField, value], + }, + }; + }); + }, + [setFilters] + ); + + const handleExclude = React.useCallback( + (field: string, value: string) => { + setFilters((prev) => { + const prevField = prev.eventDataExclude?.[field] ?? []; + if (prevField.includes(value)) return prev; + return { + ...prev, + eventDataExclude: { + ...(prev.eventDataExclude ?? {}), + [field]: [...prevField, value], + }, + }; + }); + }, + [setFilters] + ); + + const handleAddColumn = React.useCallback( + (field: string) => { + addColumn({ + id: `eventData.${field}`, + header: field, + sqlExpr: `json_extract_string(Raw, '$.Event.EventData.${field}')`, + accessor: (row) => String(row[`eventData.${field}`] ?? "-"), + width: 200, + }); + }, + [addColumn] + ); + + const sys = getSystemData(record); + return ( + + + General + + Log Name: + {sys.Channel || "-"} + + + Source: + {getProvider(sys)} + + + Event ID: + {getEventId(sys)} + + + Level: + {LEVEL_NAMES[sys.Level || 4]} + + + User: + {getUserId(sys)} + + + Logged: + {formatDateTime(getTimeCreated(sys))} + + + Computer: + {sys.Computer || "-"} + + + + {!!record.Event?.EventData && ( + + Event Data + + {renderEventData( + record.Event.EventData as EvtxEventData, + handleInclude, + handleExclude, + handleAddColumn + )} + + + )} + + {!!record.Event?.UserData && ( + + User Data +
+            {JSON.stringify(record.Event.UserData, null, 2)}
+          
+
+ )} +
+ ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/FileTree.tsx b/evtx-wasm/evtx-viewer/src/components/FileTree.tsx new file mode 100644 index 00000000..fdb25921 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FileTree.tsx @@ -0,0 +1,241 @@ +import React, { useState, useEffect, useCallback } from "react"; +import EvtxStorage from "../lib/storage"; +import styled from "styled-components"; +import { TreeView, type TreeNode, ContextMenu } from "./Windows"; +import { + Folder20Regular, + FolderOpen20Filled, + Document20Regular, + Delete20Regular, +} from "@fluentui/react-icons"; + +const TreeContainer = styled.div` + height: 100%; + overflow-y: auto; + background: ${({ theme }) => theme.colors.background.secondary}; + user-select: none; +`; + +const TreeHeader = styled.div` + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + font-weight: 600; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + background: ${({ theme }) => theme.colors.background.tertiary}; +`; + +interface EventLogNode { + id: string; + label: string; + icon?: React.ReactNode; + expandedIcon?: React.ReactNode; + children?: EventLogNode[]; + logPath?: string; + description?: string; + fileId?: string; // For cached recent logs +} + +// Built-in sample log(s) shipped with the viewer. They are served from the +// /samples/ path and are *not* downloaded until the user explicitly selects +// them. +const baseStructure: EventLogNode[] = [ + { + id: "examples", + label: "Example Logs", + icon: , + expandedIcon: , + children: [ + { + id: "sample-security", + label: "security.evtx (sample)", + icon: , + logPath: "samples/security.evtx", + description: "Built-in Windows Security log sample", + }, + ], + }, +]; + +async function fetchRecentNodes(): Promise { + const storage = await EvtxStorage.getInstance(); + const files = await storage.listFiles(); + + // sort by pinned then lastOpened desc + files.sort((a, b) => { + if (a.pinned && !b.pinned) return -1; + if (!a.pinned && b.pinned) return 1; + return b.lastOpened - a.lastOpened; + }); + + const pinnedChildren: EventLogNode[] = []; + const recentChildren: EventLogNode[] = []; + + files.forEach((f) => { + const node: EventLogNode = { + id: `recent-${f.fileId}`, + label: f.fileName, + icon: , + fileId: f.fileId, + description: `${(f.fileSize / 1024 / 1024).toFixed(1)} MB`, + }; + if (f.pinned) pinnedChildren.push(node); + else recentChildren.push(node); + }); + + const nodes: EventLogNode[] = []; + if (pinnedChildren.length) { + nodes.push({ + id: "pinned", + label: "Pinned Logs", + icon: , + expandedIcon: , + children: pinnedChildren, + }); + } + if (recentChildren.length) { + nodes.push({ + id: "recent", + label: "Recent Logs", + icon: , + expandedIcon: , + children: recentChildren, + }); + } + return nodes; +} + +interface FileTreeProps { + onNodeSelect?: (node: EventLogNode) => void; + selectedNodeId?: string; + activeFileId?: string | null; // file currently ingesting + ingestProgress?: number; // 0..1 + refreshVersion?: number; // internal, bump to force recent list update +} + +export const FileTree: React.FC = ({ + onNodeSelect, + selectedNodeId, + activeFileId, + ingestProgress = 1, + refreshVersion = 0, +}) => { + const [treeData, setTreeData] = useState(baseStructure); + + useEffect(() => { + // Load recent logs once component mounts + (async () => { + const recent = await fetchRecentNodes(); + setTreeData([...recent, ...baseStructure]); + })(); + }, [refreshVersion]); + + // refresh helper (e.g., after load) – exposed via context could be nicer + const refreshRecent = useCallback(async () => { + const recent = await fetchRecentNodes(); + setTreeData([...recent, ...baseStructure]); + }, []); + + const convertToTreeNodes = (nodes: EventLogNode[]): TreeNode[] => { + return nodes.map((node) => { + const showPct = + node.fileId && + activeFileId && + node.fileId === activeFileId && + ingestProgress < 1; + const pctRaw = ingestProgress * 100; + const pctDisplay = pctRaw < 0.01 ? 0.01 : Math.round(pctRaw * 100) / 100; // two decimals + const labelWithPct = showPct + ? `${node.label} (${pctDisplay.toFixed(2)}%)` + : node.label; + + return { + id: node.id, + label: labelWithPct, + icon: node.icon, + expandedIcon: node.expandedIcon, + children: node.children ? convertToTreeNodes(node.children) : undefined, + data: node, + }; + }); + }; + + const handleSelect = (treeNode: TreeNode) => { + const nodeId = treeNode.id; + + const findNode = (nodes: EventLogNode[]): EventLogNode | null => { + for (const node of nodes) { + if (node.id === nodeId) return node; + if (node.children) { + const found = findNode(node.children); + if (found) return found; + } + } + return null; + }; + + const selectedNode = findNode(treeData); + if (selectedNode) { + if (onNodeSelect) onNodeSelect(selectedNode); + // If a recent log was opened, bump lastOpened and refresh tree + if (selectedNode.fileId) { + refreshRecent(); + } + } + }; + + // ----------------------------- + // Context menu (right-click) + // ----------------------------- + + const [menuState, setMenuState] = useState<{ + x: number; + y: number; + target: EventLogNode; + } | null>(null); + + const handleContextMenu = (treeNode: TreeNode, e: React.MouseEvent) => { + const dataNode = treeNode.data as EventLogNode | undefined; + if (!dataNode?.fileId) return; // Only for cached files + + e.preventDefault(); + setMenuState({ x: e.clientX, y: e.clientY, target: dataNode }); + }; + + const closeMenu = useCallback(() => setMenuState(null), []); + + const handleDelete = useCallback(async () => { + if (!menuState) return; + const storage = await EvtxStorage.getInstance(); + await storage.deleteFile(menuState.target.fileId!); + await refreshRecent(); + closeMenu(); + }, [menuState, refreshRecent, closeMenu]); + + return ( + + Event Logs + + + {menuState && ( + , + onClick: handleDelete, + }, + ]} + /> + )} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FacetSection.tsx b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FacetSection.tsx new file mode 100644 index 00000000..5493d70b --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FacetSection.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { + Section, + SectionHeader, + SectionIcon, + OptionsContainer, + Counts, + Checkbox, + OptionLabel, +} from "./styles"; +import { + ChevronRight20Regular, + ChevronDown20Regular, + Search20Regular, +} from "@fluentui/react-icons"; +import { SearchContainer, SearchInput, SelectableRow } from "../Windows"; +import type { FilterOptions } from "../../lib/types"; + +// ---------------- Types ---------------- + +export interface FacetConfig { + id: string; + label: string; + filterKey?: keyof FilterOptions; + searchable?: boolean; + displayValue?: (v: string | number) => string; +} + +interface FacetSectionProps { + facet: FacetConfig; + counts: Map; + isOpen: boolean; + searchTerm: string; + toggleOpen: (key: string) => void; + onSearchTermChange: (key: string, term: string) => void; + toggleFacetValue: (facet: FacetConfig, value: string | number) => void; + selectedChecker: (val: string | number) => boolean; +} + +const FacetSection: React.FC = ({ + facet, + counts, + isOpen, + searchTerm, + toggleOpen, + onSearchTermChange, + toggleFacetValue, + selectedChecker, +}) => { + const entries = React.useMemo(() => { + const term = searchTerm.toLowerCase(); + return Array.from(counts.entries()) + .filter(([k]) => + term === "" ? true : String(k).toLowerCase().includes(term) + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 200); + }, [counts, searchTerm]); + + return ( +
+ toggleOpen(facet.id)}> + + {isOpen ? : } + + {facet.label} + + {isOpen && ( + <> + {facet.searchable && ( + + + onSearchTermChange(facet.id, e.target.value)} + /> + + )} + + {entries.map(([val, count]) => { + const selected = selectedChecker(val); + return ( + + toggleFacetValue(facet, val)} + /> + + {facet.displayValue ? facet.displayValue(val) : String(val)} + + {count} + + ); + })} + + + )} +
+ ); +}; + +export default FacetSection; diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FilterSidebar.tsx b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FilterSidebar.tsx new file mode 100644 index 00000000..06a27952 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/FilterSidebar.tsx @@ -0,0 +1,227 @@ +import React, { useMemo, useCallback, useState } from "react"; +// styled components are now centralised in styles.ts (avoids duplication) +import { + SidebarContainer, + ActiveFiltersBar, + FilterChip, + ChipRemoveBtn, +} from "./styles"; +import type { FilterOptions } from "../../lib/types"; +import { Search20Regular, Dismiss16Regular } from "@fluentui/react-icons"; +import { Button } from "../Windows"; +import { SidebarHeader } from "../Windows"; +import { logger } from "../../lib/logger"; +import FacetSection from "./FacetSection"; +import type { FacetConfig } from "./FacetSection"; +import { useFacetCounts } from "./useFacetCounts"; +import { buildFacetConfigs } from "./facetUtils"; +import { useActiveFilterChips } from "./useActiveFilterChips"; +import { useColumns } from "../../hooks/useColumns"; +import { useFilters } from "../../hooks/useFilters"; +import { useIngestState } from "../../state/store"; +import { SearchContainer, SearchInput } from "../Windows"; + +// (increment helper removed – facet counts are now sourced exclusively from DuckDB) + +export const FilterSidebar: React.FC = () => { + const { filters, setFilters } = useFilters(); + const { columns } = useColumns(); + const { progress: ingestProgress } = useIngestState(); + + const onChange = setFilters; + + const filtersDisabled = ingestProgress < 1; + + /* Debug: log whenever filters prop changes */ + React.useEffect(() => { + logger.debug("FilterSidebar filters prop changed", { filters }); + }, [filters]); + + // ---------------- Unified facet counts via hook ---------------- + const dynCounts = useFacetCounts(); + // ---------------- All facet counts storage ---------------- + + // --------------------------------------------------------------------------- + // Active filter chips – generic builder using facetConfigs + // --------------------------------------------------------------------------- + /* activeChips is defined later, after facetConfigs & toggleFacetValue */ + + // --------------------------------------------------------------------------- + // Counts resolver – single source (dynCounts) + // --------------------------------------------------------------------------- + const getCountsForFacet = useCallback( + (id: string): Map => dynCounts[id] ?? new Map(), + [dynCounts] + ); + + // --------------------------------------------------------------------------- + // Per-section UI state (collapse + search boxes) + // --------------------------------------------------------------------------- + + const [openSections, setOpenSections] = useState>({ + level: true, + provider: true, + channel: true, + eventId: false, + }); + + const toggleSection = useCallback((key: string) => { + setOpenSections((prev) => ({ ...prev, [key]: !prev[key] })); + }, []); + + const [searchTerms, setSearchTerms] = useState>({}); + const handleSearchChange = useCallback((section: string, term: string) => { + setSearchTerms((prev) => ({ ...prev, [section]: term.toLowerCase() })); + }, []); + + // --------------------------------------------------------------------------- + // Facet configuration (built-ins + dynamic columns) + // --------------------------------------------------------------------------- + + const facetConfigs: FacetConfig[] = React.useMemo( + () => buildFacetConfigs(columns), + [columns] + ); + + // --------------------------------------------------------------------------- + // Generic toggle handler – supports both built-in filters and columnEquals. + // --------------------------------------------------------------------------- + const toggleFacetValue = useCallback( + (facet: FacetConfig, value: string | number) => { + logger.debug("toggleFacetValue", { facet: facet.id, value }); + if (facet.filterKey) { + // Built-in simple array filter + const key = facet.filterKey; + const current = (filters[key] as (string | number)[] | undefined) ?? []; + const exists = current.includes(value); + const nextVals = exists + ? current.filter((v) => v !== value) + : [...current, value]; + const next = { ...filters, [key]: nextVals } as FilterOptions; + onChange(next); + } else { + // Column equality filter + const colId = facet.id; + const current = filters.columnEquals?.[colId] ?? []; + const exists = current.includes(value as string); + const nextVals = exists + ? current.filter((v) => v !== value) + : [...current, value as string]; + const nextMap = { ...(filters.columnEquals ?? {}) } as Record< + string, + string[] + >; + if (nextVals.length) nextMap[colId] = nextVals; + else delete nextMap[colId]; + onChange({ ...filters, columnEquals: nextMap }); + } + }, + [filters, onChange] + ); + + // --------------------------------------------------------------------------- + // Active filter chips – generic builder using facetConfigs + // --------------------------------------------------------------------------- + const activeChips = useActiveFilterChips( + facetConfigs, + onChange, + toggleFacetValue + ); + + // --------------------------------------------------------------------------- + // Helpers for FacetSection + // --------------------------------------------------------------------------- + + const selectedCheckerFactory = + (facet: FacetConfig) => (val: string | number) => { + if (facet.filterKey) { + const current = + (filters[facet.filterKey] as (string | number)[] | undefined) ?? []; + return current.includes(val); + } + return (filters.columnEquals?.[facet.id] ?? []).includes(val as string); + }; + + // --------------------------------------------------------------------------- + // Determine if any filters are currently active (unchanged) + // --------------------------------------------------------------------------- + const hasActiveFilters = useMemo(() => { + return ( + (filters.searchTerm && filters.searchTerm.trim() !== "") || + (filters.level && filters.level.length > 0) || + (filters.provider && filters.provider.length > 0) || + (filters.channel && filters.channel.length > 0) || + (filters.eventId && filters.eventId.length > 0) || + (filters.eventData && Object.keys(filters.eventData).length > 0) || + (filters.eventDataExclude && + Object.keys(filters.eventDataExclude).length > 0) || + (filters.columnEquals && Object.keys(filters.columnEquals).length > 0) + ); + }, [filters]); + + return ( + + + Filters + {hasActiveFilters && !filtersDisabled && ( + + )} + + + {filtersDisabled ? ( +
+ Filtering unavailable – database not initialised. +
+ ) : ( + <> + {activeChips.length > 0 && ( + + {activeChips.map((chip) => ( + + {chip.label} + + + + + ))} + + )} + {/* Search term global */} + + + + onChange({ ...filters, searchTerm: e.target.value }) + } + /> + + + {/* Unified facet sections */} + {facetConfigs.map((facet) => ( + + ))} + + )} +
+ ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/facetUtils.ts b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/facetUtils.ts new file mode 100644 index 00000000..50a8c333 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/facetUtils.ts @@ -0,0 +1,94 @@ +import type { ColumnSpec } from "../../lib/types"; +import type { FacetConfig } from "./FacetSection"; + +// Reusable helper that converts epoch-ms or ISO strings into a readable +// "YYYY-MM-DD HH:MM" 24-hour local string. +export function formatTimeValue(raw: string | number): string { + let d: Date | null = null; + if (typeof raw === "number") d = new Date(raw); + else if (typeof raw === "string") { + const num = Number(raw); + if (!Number.isNaN(num)) d = new Date(num); + else { + const parsed = new Date(raw); + if (!Number.isNaN(parsed.getTime())) d = parsed; + } + } + if (!d || Number.isNaN(d.getTime())) return String(raw); + return d.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +} + +// Mapping of Windows Event Levels to descriptive labels +const LEVEL_NAME_MAP: Record = { + 0: "LogAlways", + 1: "Critical", + 2: "Error", + 3: "Warning", + 4: "Information", + 5: "Verbose", +}; + +// Columns that should not be shown as facet buckets. Currently only the +// Certain high-cardinality columns are excluded from equality-style faceting. +// (The timestamp column is now supported via adaptive time-bucket grouping.) +export const EXCLUDE_FROM_FACETS = new Set([ + /* add ids here as needed */ +]); + +/** + * Build the complete list of facet configurations given the current active + * table columns. + * + * – Built-in facets are always present (level, provider, channel, eventId) + * – Any additional column that is not one of the built-ins becomes a dynamic + * facet so users can filter on arbitrary extracted fields. + */ +export function buildFacetConfigs(columns: ColumnSpec[]): FacetConfig[] { + const builtins: FacetConfig[] = [ + { + id: "level", + label: "Level", + filterKey: "level", + displayValue: (v) => LEVEL_NAME_MAP[v as number] || String(v), + }, + { + id: "time", + label: "Date / Time", + // Uses columnEquals on "time" so no simple filterKey + displayValue: (v) => formatTimeValue(v), + }, + { + id: "provider", + label: "Provider", + filterKey: "provider", + searchable: true, + }, + { + id: "channel", + label: "Channel", + filterKey: "channel", + searchable: true, + }, + { + id: "eventId", + label: "Event ID", + filterKey: "eventId", + }, + ]; + + const dynamicCols: FacetConfig[] = columns + .filter( + (c) => + !builtins.some((b) => b.id === c.id) && !EXCLUDE_FROM_FACETS.has(c.id) + ) + .map((c) => ({ id: c.id, label: c.header })); + + return [...builtins, ...dynamicCols]; +} diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/index.ts b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/index.ts new file mode 100644 index 00000000..69e60f7c --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/index.ts @@ -0,0 +1 @@ +export { FilterSidebar } from "./FilterSidebar"; diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/styles.ts b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/styles.ts new file mode 100644 index 00000000..543fc3f9 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/styles.ts @@ -0,0 +1,115 @@ +import styled, { css } from "styled-components"; + +// ---------------- Shared styled-components for FilterSidebar ---------------- + +// ---- Container & layout ---- +export const SidebarContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + background: ${({ theme }) => theme.colors.background.secondary}; + padding-left: 3px; /* Account for the resize divider */ + /* Allow entire sidebar to scroll when content exceeds viewport */ + overflow-y: auto; +`; + +export const ActiveFiltersBar = styled.div` + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + background: ${({ theme }) => theme.colors.background.tertiary}; +`; + +// ---- Filter chips ---- +export const FilterChip = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + background: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.caption}; + color: ${({ theme }) => theme.colors.text.primary}; +`; + +export const ChipRemoveBtn = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: inherit; + line-height: 1; +`; + +// ---- Facet section ---- + +export const Section = styled.div``; + +export const SectionHeader = styled.button<{ $isOpen: boolean }>` + display: flex; + align-items: center; + width: 100%; + background: ${({ theme }) => theme.colors.background.secondary}; + border: none; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + font-size: ${({ theme }) => theme.fontSize.body}; + cursor: pointer; + color: ${({ theme }) => theme.colors.text.primary}; + user-select: none; + transition: background-color ${({ theme }) => theme.transitions.fast}; + + &:hover { + background-color: ${({ theme }) => theme.colors.background.hover}; + } + + ${({ $isOpen, theme }) => + $isOpen && + css` + background-color: ${theme.colors.background.hover}; + `} +`; + +export const SectionIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: ${({ theme }) => theme.spacing.sm}; +`; + +export const OptionsContainer = styled.div` + max-height: 240px; + overflow: auto; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md} + ${({ theme }) => theme.spacing.md}; + + /* Bottom border when content is scrollable */ + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; +`; + +export const Counts = styled.span` + color: ${({ theme }) => theme.colors.text.tertiary}; +`; + +export const Checkbox = styled.input.attrs({ type: "checkbox" })` + width: 14px; + height: 14px; + margin: 0; + cursor: pointer; + accent-color: ${({ theme }) => theme.colors.accent.primary}; +`; + +export const OptionLabel = styled.span` + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useActiveFilterChips.ts b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useActiveFilterChips.ts new file mode 100644 index 00000000..8b8c5527 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useActiveFilterChips.ts @@ -0,0 +1,107 @@ +import { useMemo } from "react"; +import type { FacetConfig } from "./FacetSection"; +import type { FilterOptions } from "../../lib/types"; +import { useFiltersState } from "../../state/store"; + +export interface ActiveChip { + key: string; + label: string; + remove: () => void; +} + +/** + * Derive the list of active filter "chips" for display in the sidebar. + * All remove callbacks are fully memoised so the consumer can pass them + * straight to the chip UI component without additional wrappers. + */ +export function useActiveFilterChips( + facetConfigs: FacetConfig[], + onChange: (next: FilterOptions) => void, + toggleFacetValue: (facet: FacetConfig, value: string | number) => void +): ActiveChip[] { + const filters = useFiltersState(); + return useMemo(() => { + const chips: ActiveChip[] = []; + + // Global search term + if (filters.searchTerm && filters.searchTerm.trim() !== "") { + chips.push({ + key: "search", + label: `Search: "${filters.searchTerm.trim()}"`, + remove: () => onChange({ ...filters, searchTerm: "" }), + }); + } + + // Facet-based chips (built-ins + dynamic columns) + facetConfigs.forEach((facet) => { + let values: (string | number)[] = []; + if (facet.filterKey) { + values = + (filters[facet.filterKey as keyof FilterOptions] as + | (string | number)[] + | undefined) ?? []; + } else { + values = filters.columnEquals?.[facet.id] ?? []; + } + + values.forEach((v) => { + const display = facet.displayValue ? facet.displayValue(v) : String(v); + const label = `${facet.label}: ${display}`; + chips.push({ + key: `${facet.id}-${v}`, + label, + remove: () => toggleFacetValue(facet, v), + }); + }); + }); + + // EventData include chips + (filters.eventData ? Object.entries(filters.eventData) : []).forEach( + ([field, vals]) => { + vals.forEach((v) => { + chips.push({ + key: `ed-${field}-${v}`, + label: `${field}: ${v}`, + remove: () => { + const currentVals = filters.eventData![field] ?? []; + const newVals = currentVals.filter((x) => x !== v); + const newEventData = { ...filters.eventData } as Record< + string, + string[] + >; + if (newVals.length) newEventData[field] = newVals; + else delete newEventData[field]; + onChange({ ...filters, eventData: newEventData }); + }, + }); + }); + } + ); + + // EventData exclude chips + (filters.eventDataExclude + ? Object.entries(filters.eventDataExclude) + : [] + ).forEach(([field, vals]) => { + vals.forEach((v) => { + chips.push({ + key: `edex-${field}-${v}`, + label: `¬${field}: ${v}`, + remove: () => { + const currentVals = filters.eventDataExclude![field] ?? []; + const newVals = currentVals.filter((x) => x !== v); + const newEventDataEx = { ...filters.eventDataExclude } as Record< + string, + string[] + >; + if (newVals.length) newEventDataEx[field] = newVals; + else delete newEventDataEx[field]; + onChange({ ...filters, eventDataExclude: newEventDataEx }); + }, + }); + }); + }); + + return chips; + }, [filters, facetConfigs, onChange, toggleFacetValue]); +} diff --git a/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useFacetCounts.ts b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useFacetCounts.ts new file mode 100644 index 00000000..16043eb9 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/FilterSidebar/useFacetCounts.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { getColumnFacetCounts } from "../../lib/duckdb"; +import { EXCLUDE_FROM_FACETS } from "./facetUtils"; +import { + useColumnsState, + useFiltersState, + useIngestState, +} from "../../state/store"; +import type { ColumnSpec } from "../../lib/types"; + +/** + * Hook that derives facet value → count maps for the active columns + * using DuckDB. Counts recompute automatically whenever filters, columns or + * ingest progress change (sourced from the global store). + */ +export function useFacetCounts(): Record> { + const columns = useColumnsState(); + const filters = useFiltersState(); + const { progress: ingestProgress } = useIngestState(); + const [counts, setCounts] = useState>>({}); + + // Exclusion list comes from facetUtils so the set stays centralised. + + useEffect(() => { + if (ingestProgress < 1) return; + + let cancelled = false; + + (async () => { + const out: Record> = {}; + + // Ensure Channel column spec exists for facet counts even if not visible. + const specs: ColumnSpec[] = [...columns]; + if (!specs.some((c) => c.id === "channel")) { + specs.push({ id: "channel", header: "Channel", sqlExpr: "Channel" }); + } + + const facetableSpecs = specs.filter( + (spec) => !EXCLUDE_FROM_FACETS.has(spec.id) + ); + + await Promise.all( + facetableSpecs.map(async (spec) => { + try { + const res = await getColumnFacetCounts(spec, filters, 200); + const m = new Map(); + res.forEach(({ v, c }) => m.set(String(v), Number(c))); + out[spec.id] = m; + } catch (err) { + console.warn(`facet counts failed for ${spec.id}`, err); + } + }) + ); + + if (!cancelled) setCounts(out); + })(); + + return () => { + cancelled = true; + }; + }, [columns, filters, ingestProgress]); + + return counts; +} diff --git a/evtx-wasm/evtx-viewer/src/components/LogRow.tsx b/evtx-wasm/evtx-viewer/src/components/LogRow.tsx new file mode 100644 index 00000000..d718fda3 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/LogRow.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import styled from "styled-components"; +// (EvtxRecord no longer needed) + +// Removed unused icons and helper constants; cell rendering now delegated to +// the column accessor functions supplied via props. + +const TR = styled.tr<{ $isSelected?: boolean; $isEven?: boolean }>` + height: 30px; /* Keep in sync with ROW_HEIGHT in LogTableVirtual */ + background: ${({ theme, $isSelected, $isEven }) => + $isSelected + ? theme.colors.selection.background + : $isEven + ? theme.colors.background.tertiary + : theme.colors.background.secondary}; + cursor: pointer; + + &:hover { + background: ${({ theme, $isSelected }) => + $isSelected + ? theme.colors.selection.background + : theme.colors.background.hover}; + } +`; + +const TD = styled.td` + padding: 4px 8px; + border-right: 1px solid ${({ theme }) => theme.colors.border.light}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:last-child { + border-right: none; + } +`; + +export interface LogRowProps { + record: Record; + isEven: boolean; + isSelected: boolean; + onRowClick: (idx: number) => void; + /** Global row index – used for keyboard navigation */ + rowIndex: number; + /** Columns definition – determines which cells are rendered */ + columns: import("../lib/types").TableColumn[]; +} + +export const LogRow: React.FC = React.memo( + ({ record: rec, isEven, isSelected, onRowClick, rowIndex, columns }) => ( + onRowClick(rowIndex)} + data-row-idx={rowIndex} + > + {columns.map((col) => ( + + { + (col.accessor + ? col.accessor(rec) + : String(rec[col.id] ?? "-")) as React.ReactNode + } + + ))} + + ) +); + +LogRow.displayName = "LogRow"; diff --git a/evtx-wasm/evtx-viewer/src/components/LogTableVirtual.tsx b/evtx-wasm/evtx-viewer/src/components/LogTableVirtual.tsx new file mode 100644 index 00000000..22a6f4e4 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/LogTableVirtual.tsx @@ -0,0 +1,539 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useCallback, useMemo, useState, useRef } from "react"; +import styled from "styled-components"; + +import type { EvtxRecord, FilterOptions, TableColumn } from "../lib/types"; +import { DuckDbDataSource } from "../lib/duckDbDataSource"; +import { EventDetailsPane } from "./EventDetailsPane"; +import { computeSliceRows } from "../lib/computeSliceRows"; +import { useChunkVirtualizer } from "../lib/useChunkVirtualizer"; +import { LogRow } from "./LogRow"; +import { useRowNavigation } from "./useRowNavigation"; +import { logger } from "../lib/logger"; +import { ContextMenu, type ContextMenuItem } from "./Windows"; +import { getColumnFacetCounts } from "../lib/duckdb"; +import { useFilters } from "../hooks/useFilters"; +import { useColumns } from "../hooks/useColumns"; +import type { VirtualItem } from "@tanstack/react-virtual"; +import { Filter20Regular } from "@fluentui/react-icons"; +import { formatTimeValue } from "./FilterSidebar/facetUtils"; + +// ------------------------------------------------------------------ +// Helper to build the list for the current viewport. Extracted out of +// JSX to keep the main component lean and readable. +// ------------------------------------------------------------------ + +interface GenerateRowsArgs { + vItems: VirtualItem[]; + chunkRows: Map; // generic rows + columnsCount: number; + tableContainerRef: React.MutableRefObject; + prefix: number[]; + selectedRow: number | null; + handleRowClick: (idx: number) => void; + ROW_HEIGHT: number; + SLICE_BUFFER_ROWS: number; + MAX_ROWS_PER_SLICE: number; + virtualizerTotal: number; + columns: TableColumn[]; +} + +function generateRows({ + vItems, + chunkRows, + columnsCount, + tableContainerRef, + prefix, + selectedRow, + handleRowClick, + ROW_HEIGHT, + SLICE_BUFFER_ROWS, + MAX_ROWS_PER_SLICE, + virtualizerTotal, + columns, +}: GenerateRowsArgs): React.ReactNode[] { + const rows: React.ReactNode[] = []; + if (vItems.length === 0) return rows; + + // Spacer before first visible chunk + if (vItems[0].start > 0) { + rows.push( + + + + ); + } + + vItems.forEach((vi, idx) => { + const chunkIdx = vi.index; + const records = chunkRows.get(chunkIdx); + + if (!records) { + // Placeholder row while chunk loading + rows.push( + + + Loading chunk {chunkIdx}… + + + ); + return; // Continue to spacer between chunks + } + + const startGlobal = prefix[chunkIdx] ?? 0; + const scrollEl = tableContainerRef.current; + const viewportStart = scrollEl?.scrollTop ?? 0; + const viewportHeight = scrollEl?.clientHeight ?? 0; + const viewportEnd = viewportStart + viewportHeight; + + const bufferPx = SLICE_BUFFER_ROWS * ROW_HEIGHT; + const chunkTop = vi.start; + const chunkBottom = vi.start + vi.size; + + // Skip if chunk outside buffered viewport + if ( + viewportEnd + bufferPx <= chunkTop || + viewportStart - bufferPx >= chunkBottom + ) { + rows.push( + + + + ); + return; + } + + const slice = computeSliceRows({ + viewportStart, + viewportHeight, + chunkTop, + chunkHeight: vi.size, + rowHeight: ROW_HEIGHT, + bufferRows: SLICE_BUFFER_ROWS, + maxRows: MAX_ROWS_PER_SLICE, + recordCount: records.length, + }); + + if (!slice) { + rows.push( + + + + ); + return; + } + + const [sliceStartRow, sliceEndRow] = slice; + + logger.debug("renderSlice", { chunkIdx, sliceStartRow, sliceEndRow }); + + // Top spacer inside chunk + const topSpacerHeight = sliceStartRow * ROW_HEIGHT; + if (topSpacerHeight > 0) { + rows.push( + + + + ); + } + + // Actual visible rows + records.slice(sliceStartRow, sliceEndRow + 1).forEach((rec, localIdx) => { + const rowI = sliceStartRow + localIdx; + const globalIdx = startGlobal + rowI; + const isEven = globalIdx % 2 === 0; + const isSelected = selectedRow === globalIdx; + rows.push( + + ); + }); + + // Bottom spacer inside chunk + const bottomSpacerHeight = (records.length - sliceEndRow - 1) * ROW_HEIGHT; + if (bottomSpacerHeight > 0) { + rows.push( + + + + ); + } + + // Spacer between this chunk and next + const next = vItems[idx + 1]; + if (next) { + const gap = next.start - (vi.start + vi.size); + if (gap > 0) { + rows.push( + + + + ); + } + } + }); + + // Spacer after last visible chunk + const last = vItems[vItems.length - 1]; + rows.push( + + + + ); + + return rows; +} + +interface LogTableVirtualProps { + dataSource: DuckDbDataSource; + onRowSelect?: (rec: EvtxRecord) => void; +} + +// ---------- styled basics (slimmed down from original LogTable) ---------- + +const Container = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +`; + +const TableContainer = styled.div` + flex: 1; + overflow: auto; + position: relative; +`; + +const Table = styled.table` + width: 100%; + border-collapse: collapse; + table-layout: fixed; +`; + +const THead = styled.thead` + position: sticky; + top: 0; + z-index: 10; + background: ${({ theme }) => theme.colors.background.secondary}; +`; + +const TBody = styled.tbody``; + +const TH = styled.th` + text-align: left; + padding: 6px 8px; + border-right: 1px solid ${({ theme }) => theme.colors.border.light}; + border-bottom: 2px solid ${({ theme }) => theme.colors.border.medium}; + background: ${({ theme }) => theme.colors.background.secondary}; + font-weight: 600; + user-select: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &:last-child { + border-right: none; + } +`; + +const THInner = styled.div<{ $filtered?: boolean }>` + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + ::after { + content: ""; + } + svg { + opacity: ${({ $filtered }) => ($filtered ? 1 : 0.4)}; + color: ${({ theme, $filtered }) => + $filtered ? theme.colors.accent.primary : theme.colors.text.secondary}; + } +`; + +// Add styled divider for resizing like original +const Divider = styled.div` + height: 2px; + cursor: row-resize; + background: ${({ theme }) => theme.colors.border.light}; + flex-shrink: 0; + transition: background 0.2s ease; + &:hover { + background: ${({ theme }) => theme.colors.accent.primary}; + } +`; + +// ------------------------------------------------------------------------ + +export const LogTableVirtual: React.FC = ({ + dataSource, + onRowSelect, +}) => { + const { filters: currentFilters, setFilters } = useFilters(); + const { columns } = useColumns(); + + const ROW_HEIGHT = 30; // single source of truth for row height + const MAX_ROWS_PER_SLICE = 5000; // increased to load a few thousand rows at once + const SLICE_BUFFER_ROWS = 2000; // expanded buffer size so more rows stay mounted + + const { + containerRef: tableContainerRef, + virtualizer, + chunkRows, + prefix, + totalRows, + } = useChunkVirtualizer({ + dataSource, + rowHeight: ROW_HEIGHT, + }); + + const getRowRecord = useCallback( + (globalIdx: number): EvtxRecord | null => { + let c = 0; + while (c + 1 < prefix.length && prefix[c + 1] <= globalIdx) c++; + const rows = chunkRows.get(c); + if (!rows) return null; + const row: any = rows[globalIdx - prefix[c]]; + if (!row) return null; + const raw = row["Raw"] as string | undefined; + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } + }, + [chunkRows, prefix] + ); + + const columnsMemo = useMemo(() => columns, [columns]); + + // Optional: expose scroll position for debugging / analytics + const handleScroll = useCallback(() => { + if (tableContainerRef.current) { + logger.debug("scroll", { + scrollTop: tableContainerRef.current.scrollTop, + }); + } + }, [tableContainerRef]); + + /* ------------------------------------------------------------------ + * Row selection handling + * ------------------------------------------------------------------ + * Besides tracking the numeric row index for highlighting / keyboard + * navigation, we also keep a reference to the *actual* EvtxRecord that + * was selected. This guarantees the EventDetailsPane can stay mounted + * across filter changes because the record object itself does not change + * when our virtualisation layers re-compute indices or re-order rows. + */ + + const [selectedRecord, setSelectedRecord] = useState(null); + + // Wrap the optional onRowSelect prop so we can update our own state first + const handleRowSelect = useCallback( + (rec: EvtxRecord) => { + setSelectedRecord(rec); + if (onRowSelect) onRowSelect(rec); + }, + [onRowSelect] + ); + + const { selectedRow, handleKeyDown, handleRowClick } = useRowNavigation({ + totalRows, + getRowRecord, + onRowSelect: handleRowSelect, + scrollContainerRef: tableContainerRef, + rowHeight: ROW_HEIGHT, + }); + + // We intentionally keep the selected record even if it no longer appears + // in the filtered result set so the details pane remains visible while the + // user tweaks filters. It will be cleared automatically once the user + // selects a different row or when a new file is loaded. + + // Height of the details pane (resizable via divider) + const [detailsHeight, setDetailsHeight] = useState(200); + + // outer container ref (for mouse move calculations during resize) + const outerRef = useRef(null); + + // adjust divider + const handleDividerMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startY = e.clientY; + const startHeight = detailsHeight; + + const onMouseMove = (me: MouseEvent) => { + if (!outerRef.current) return; + const deltaY = me.clientY - startY; + const newH = Math.max(100, startHeight - deltaY); + setDetailsHeight(newH); + }; + + const onMouseUp = () => { + document.removeEventListener("mousemove", onMouseMove); + document.removeEventListener("mouseup", onMouseUp); + }; + + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + }, + [detailsHeight] + ); + + // --------------------------------------------- + // Header filter pop-over state + // --------------------------------------------- + const [filterMenu, setFilterMenu] = useState<{ + col: TableColumn; + pos: { x: number; y: number }; + items: { v: string; c: number }[]; + } | null>(null); + + const openFilterMenu = async (col: TableColumn, e: React.MouseEvent) => { + e.preventDefault(); + const counts = await getColumnFacetCounts(col, currentFilters); + setFilterMenu({ + col, + pos: { x: e.clientX, y: e.clientY }, + items: counts.map(({ v, c }) => ({ v: v as any, c })), + }); + }; + + const toggleValue = (val: string) => { + setFilters((prev) => { + const cur = prev.columnEquals?.[filterMenu!.col.id] ?? []; + const exists = cur.includes(val); + const nextVals = exists + ? cur.filter((x: string) => x !== val) + : [...cur, val]; + return { + ...prev, + columnEquals: { + ...(prev.columnEquals ?? {}), + [filterMenu!.col.id]: nextVals, + }, + } as FilterOptions; + }); + }; + + return ( + + +
+ + + + {columnsMemo.map((col) => ( + + ))} + + + + {generateRows({ + vItems: virtualizer.getVirtualItems(), + chunkRows, + columnsCount: columnsMemo.length, + tableContainerRef, + prefix, + selectedRow, + handleRowClick, + ROW_HEIGHT, + SLICE_BUFFER_ROWS, + MAX_ROWS_PER_SLICE, + virtualizerTotal: virtualizer.getTotalSize(), + columns: columnsMemo, + })} + +
openFilterMenu(col, e)} + > + {(() => { + const isFiltered = Boolean( + currentFilters.columnEquals?.[col.id]?.length + ); + return ( + openFilterMenu(col, ev as any)} + > + {col.header} + + + ); + })()} +
+
+
+ + {selectedRecord && ( + <> + + + + )} + {filterMenu && + (() => { + const current = + currentFilters.columnEquals?.[filterMenu.col.id] ?? []; + const menuItems: ContextMenuItem[] = [ + { + id: "select-all", + label: "(Select All)", + onClick: () => { + setFilters( + (prev) => + ({ + ...prev, + columnEquals: { + ...(prev.columnEquals ?? {}), + [filterMenu.col.id]: [], + }, + } as FilterOptions) + ); + }, + }, + ]; + const isTimeCol = filterMenu.col.id === "time"; + filterMenu.items.forEach(({ v, c }) => { + const labelVal = isTimeCol ? formatTimeValue(v) : String(v ?? "-"); + menuItems.push({ + id: v, + label: `${labelVal} (${c})`, + icon: ( + + ), + onClick: () => toggleValue(v), + }); + }); + return ( + setFilterMenu(null)} + /> + ); + })()} +
+ ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/StatusBar.tsx b/evtx-wasm/evtx-viewer/src/components/StatusBar.tsx new file mode 100644 index 00000000..85f984ce --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/StatusBar.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import styled from "styled-components"; +import { useEvtxMetaState, useIngestState } from "../state/store"; + +const Bar = styled.div` + height: 24px; + background: ${({ theme }) => theme.colors.background.secondary}; + border-top: 1px solid ${({ theme }) => theme.colors.border.light}; + display: flex; + align-items: center; + padding: 0 ${({ theme }) => theme.spacing.sm}; + font-size: ${({ theme }) => theme.fontSize.caption}; + color: ${({ theme }) => theme.colors.text.secondary}; + gap: ${({ theme }) => theme.spacing.lg}; +`; + +const Item = styled.span` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; +`; + +interface StatusBarProps { + isWasmReady: boolean; + isDuckDbReady: boolean; +} + +export const StatusBar: React.FC = ({ + isWasmReady, + isDuckDbReady, +}) => { + const { fileInfo, matchedCount, totalRecords } = useEvtxMetaState(); + const { progress: ingestProgress } = useIngestState(); + + const eventCountDisplay = fileInfo + ? `${fileInfo.fileName} - ${matchedCount}/${totalRecords || 0} events` + : "No file loaded"; + + const chunkCountDisplay = fileInfo ? `Chunks: ${fileInfo.totalChunks}` : null; + + let progressDisplay: string; + + if (!isWasmReady) { + progressDisplay = "Loading WASM..."; + } else if (!isDuckDbReady) { + progressDisplay = "Loading DB engine..."; + } else if (ingestProgress < 1 && fileInfo) { + progressDisplay = `Loading DB ${ + ingestProgress * 100 < 0.01 && ingestProgress > 0 + ? 0.01 + : Math.round(ingestProgress * 10000) / 100 + }%`; + } else { + progressDisplay = "Ready"; + } + + return ( + + {eventCountDisplay} + {chunkCountDisplay && {chunkCountDisplay}} + {progressDisplay} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/Button.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/Button.tsx new file mode 100644 index 00000000..3d7cc56c --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/Button.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +export interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'subtle'; + size?: 'small' | 'medium' | 'large'; + fullWidth?: boolean; + icon?: React.ReactNode; +} + +const sizeStyles = { + small: css` + padding: 4px 12px; + font-size: ${({ theme }) => theme.fontSize.caption}; + min-height: 24px; + `, + medium: css` + padding: 6px 16px; + font-size: ${({ theme }) => theme.fontSize.body}; + min-height: 32px; + `, + large: css` + padding: 8px 20px; + font-size: ${({ theme }) => theme.fontSize.subtitle}; + min-height: 40px; + ` +}; + +const variantStyles = { + primary: css` + background-color: ${({ theme }) => theme.colors.accent.primary}; + color: ${({ theme }) => theme.colors.text.white}; + border: 1px solid ${({ theme }) => theme.colors.accent.primary}; + + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.accent.hover}; + border-color: ${({ theme }) => theme.colors.accent.hover}; + } + + &:active:not(:disabled) { + background-color: ${({ theme }) => theme.colors.accent.active}; + border-color: ${({ theme }) => theme.colors.accent.active}; + } + `, + secondary: css` + background-color: ${({ theme }) => theme.colors.background.secondary}; + color: ${({ theme }) => theme.colors.text.primary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.hover}; + border-color: ${({ theme }) => theme.colors.border.dark}; + } + + &:active:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.active}; + border-color: ${({ theme }) => theme.colors.accent.primary}; + } + `, + subtle: css` + background-color: transparent; + color: ${({ theme }) => theme.colors.text.primary}; + border: 1px solid transparent; + + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.hover}; + border-color: ${({ theme }) => theme.colors.border.light}; + } + + &:active:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.active}; + border-color: ${({ theme }) => theme.colors.border.medium}; + } + ` +}; + +const StyledButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.spacing.sm}; + font-family: ${({ theme }) => theme.fonts.body}; + font-weight: 400; + line-height: 1.5; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + cursor: pointer; + transition: all ${({ theme }) => theme.transitions.fast}; + user-select: none; + outline: none; + position: relative; + white-space: nowrap; + + ${({ size = 'medium' }) => sizeStyles[size]} + ${({ variant = 'secondary' }) => variantStyles[variant]} + ${({ fullWidth }) => fullWidth && css` + width: 100%; + `} + + &:focus-visible { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.accent.primary}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Ripple effect on click */ + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.1); + transform: translate(-50%, -50%); + transition: width 0.3s, height 0.3s; + } + + &:active::after { + width: 100%; + height: 100%; + } +`; + +const IconWrapper = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +`; + +export const Button: React.FC = ({ children, icon, ...props }) => { + return ( + + {icon && {icon}} + {children} + + ); +}; \ No newline at end of file diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/ContextMenu.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/ContextMenu.tsx new file mode 100644 index 00000000..dbeb3619 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/ContextMenu.tsx @@ -0,0 +1,118 @@ +import React, { useEffect, useRef } from "react"; +import styled from "styled-components"; + +export interface ContextMenuItem { + id: string; + label: string; + icon?: React.ReactNode; + disabled?: boolean; + onClick?: () => void; +} + +export interface ContextMenuProps { + items: ContextMenuItem[]; + /** Absolute viewport pixel coordinates */ + position: { x: number; y: number }; + onClose: () => void; + className?: string; + style?: React.CSSProperties; +} + +const MenuContainer = styled.div<{ $x: number; $y: number }>` + position: fixed; + left: ${({ $x }) => $x}px; + top: ${({ $y }) => $y}px; + background-color: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + box-shadow: ${({ theme }) => theme.shadows.elevation}; + padding: ${({ theme }) => theme.spacing.xs} 0; + z-index: 2000; + min-width: 160px; + user-select: none; +`; + +const MenuItemRow = styled.div<{ $disabled?: boolean }>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.sm}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.lg}; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)}; + + &:hover { + background-color: ${({ theme, $disabled }) => + $disabled ? "inherit" : theme.colors.selection.background}; + } +`; + +const ItemIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const ItemLabel = styled.span` + flex: 1; +`; + +export const ContextMenu: React.FC = ({ + items, + position, + onClose, + className, + style, +}) => { + const containerRef = useRef(null); + + // Close on outside click or Esc key + useEffect(() => { + const handleMouseDown = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + + document.addEventListener("mousedown", handleMouseDown); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleMouseDown); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onClose]); + + return ( + + {items.map((item) => ( + { + if (item.disabled) return; + if (item.onClick) item.onClick(); + onClose(); + }} + > + {item.icon && {item.icon}} + {item.label} + + ))} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/Dropdown.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/Dropdown.tsx new file mode 100644 index 00000000..defe3cc1 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/Dropdown.tsx @@ -0,0 +1,169 @@ +import React, { useState, useRef, useCallback, useEffect } from "react"; +import styled from "styled-components"; +import { ChevronDown20Regular } from "@fluentui/react-icons"; + +export interface DropdownOption { + label: string; + value: T; + icon?: React.ReactNode; + disabled?: boolean; +} + +export interface DropdownProps { + options: DropdownOption[]; + value: T; + onChange: (value: T) => void; + label?: string; + disabled?: boolean; + /** Optionally override the pop-over min-width */ + width?: number | string; + className?: string; + style?: React.CSSProperties; +} + +const Wrapper = styled.div` + position: relative; + display: inline-block; +`; + +const ToggleButton = styled.button<{ $disabled?: boolean }>` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.sm}; + min-height: 32px; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + color: ${({ theme }) => theme.colors.text.primary}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSize.body}; + transition: all ${({ theme }) => theme.transitions.fast}; + + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.hover}; + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.accent.primary}; + } +`; + +const ValueLabel = styled.span` + white-space: nowrap; +`; + +const DropdownIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const Menu = styled.div<{ $isOpen: boolean; $width?: number | string }>` + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: ${({ $width }) => + typeof $width === "number" ? `${$width}px` : $width || "100%"}; + background-color: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + box-shadow: ${({ theme }) => theme.shadows.elevation}; + z-index: 1001; + opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)}; + visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; + transform: ${({ $isOpen }) => + $isOpen ? "translateY(0)" : "translateY(-4px)"}; + transition: all ${({ theme }) => theme.transitions.fast}; + max-height: 240px; + overflow-y: auto; +`; + +const MenuItem = styled.div<{ + $isSelected?: boolean; + $disabled?: boolean; +}>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.sm}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + color: ${({ theme, $disabled }) => + $disabled ? theme.colors.text.tertiary : theme.colors.text.primary}; + background-color: ${({ $isSelected, theme }) => + $isSelected ? theme.colors.selection.background : "transparent"}; + + &:hover { + background-color: ${({ theme, $disabled }) => + $disabled ? "transparent" : theme.colors.background.hover}; + } +`; + +export function Dropdown({ + options, + value, + onChange, + label, + disabled, + width, + className, + style, +}: DropdownProps) { + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + + const toggleOpen = useCallback(() => { + if (disabled) return; + setOpen((prev) => !prev); + }, [disabled]); + + const handleClickOutside = useCallback((e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + } + }, []); + + useEffect(() => { + if (open) { + document.addEventListener("mousedown", handleClickOutside); + } else { + document.removeEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [open, handleClickOutside]); + + const selected = options.find((o) => o.value === value); + + return ( + + + {label && {label}:} + {selected ? selected.label : ""} + + + + + + + {options.map((opt) => ( + { + if (opt.disabled) return; + onChange(opt.value); + setOpen(false); + }} + > + {opt.icon && {opt.icon}} + {opt.label} + + ))} + + + ); +} diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/MenuBar.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/MenuBar.tsx new file mode 100644 index 00000000..ccf581d2 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/MenuBar.tsx @@ -0,0 +1,258 @@ +import React, { useState, useRef, useEffect, useCallback } from "react"; +import styled, { css } from "styled-components"; + +export interface MenuItem { + id: string; + label: string; + onClick?: () => void; + submenu?: MenuItem[]; + disabled?: boolean; + separator?: boolean; + icon?: React.ReactNode; + shortcut?: string; +} + +export interface MenuBarProps { + items: MenuItem[]; + className?: string; +} + +const MenuBarContainer = styled.div` + display: flex; + align-items: center; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSize.body}; + user-select: none; + position: relative; + z-index: 1000; +`; + +const MenuBarItem = styled.div<{ $isOpen?: boolean }>` + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; + cursor: pointer; + position: relative; + transition: background-color ${({ theme }) => theme.transitions.fast}; + + &:hover { + background-color: ${({ theme }) => theme.colors.background.hover}; + } + + ${({ $isOpen, theme }) => + $isOpen && + css` + background-color: ${theme.colors.background.active}; + `} +`; + +const Dropdown = styled.div<{ $isOpen: boolean }>` + position: absolute; + top: 100%; + left: 0; + min-width: 200px; + background-color: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + box-shadow: ${({ theme }) => theme.shadows.elevation}; + padding: ${({ theme }) => theme.spacing.xs} 0; + opacity: ${({ $isOpen }) => ($isOpen ? 1 : 0)}; + visibility: ${({ $isOpen }) => ($isOpen ? "visible" : "hidden")}; + transform: ${({ $isOpen }) => + $isOpen ? "translateY(0)" : "translateY(-4px)"}; + transition: all ${({ theme }) => theme.transitions.fast}; + z-index: 1001; +`; + +const DropdownItem = styled.div<{ $disabled?: boolean; $hasSubmenu?: boolean }>` + display: flex; + align-items: center; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.lg}; + cursor: ${({ $disabled }) => ($disabled ? "not-allowed" : "pointer")}; + opacity: ${({ $disabled }) => ($disabled ? 0.5 : 1)}; + position: relative; + + &:hover { + background-color: ${({ theme, $disabled }) => + $disabled ? "inherit" : theme.colors.selection.background}; + } + + ${({ $hasSubmenu }) => + $hasSubmenu && + css` + &::after { + content: "▶"; + position: absolute; + right: 12px; + font-size: 10px; + color: ${({ theme }) => theme.colors.text.secondary}; + } + `} +`; + +const MenuIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: ${({ theme }) => theme.spacing.sm}; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const MenuLabel = styled.span` + flex: 1; +`; + +const MenuShortcut = styled.span` + margin-left: ${({ theme }) => theme.spacing.xl}; + color: ${({ theme }) => theme.colors.text.tertiary}; + font-size: ${({ theme }) => theme.fontSize.caption}; +`; + +const Separator = styled.div` + height: 1px; + background-color: ${({ theme }) => theme.colors.border.light}; + margin: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; +`; + +const Submenu = styled(Dropdown)<{ $parentWidth: number }>` + left: 100%; + top: -4px; + margin-left: 4px; +`; + +interface MenuItemComponentProps { + item: MenuItem; + onClose: () => void; + level?: number; +} + +const MenuItemComponent: React.FC = ({ + item, + onClose, + level = 0, +}) => { + const [submenuOpen, setSubmenuOpen] = useState(false); + const itemRef = useRef(null); + const [itemWidth, setItemWidth] = useState(0); + + useEffect(() => { + if (itemRef.current) { + setItemWidth(itemRef.current.offsetWidth); + } + }, []); + + const handleClick = useCallback(() => { + if (!item.disabled && item.onClick) { + item.onClick(); + onClose(); + } + }, [item, onClose]); + + const handleMouseEnter = useCallback(() => { + if (item.submenu && !item.disabled) { + setSubmenuOpen(true); + } + }, [item]); + + const handleMouseLeave = useCallback(() => { + setSubmenuOpen(false); + }, []); + + if (item.separator) { + return ; + } + + return ( + + {item.icon && {item.icon}} + {item.label} + {item.shortcut && {item.shortcut}} + {item.submenu && ( + + {item.submenu.map((subItem) => ( + + ))} + + )} + + ); +}; + +export const MenuBar: React.FC = ({ items, className }) => { + const [openMenuId, setOpenMenuId] = useState(null); + const menuBarRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + menuBarRef.current && + !menuBarRef.current.contains(event.target as Node) + ) { + setOpenMenuId(null); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + const handleMenuClick = useCallback( + (itemId: string) => { + setOpenMenuId(openMenuId === itemId ? null : itemId); + }, + [openMenuId] + ); + + const handleMenuHover = useCallback( + (itemId: string) => { + if (openMenuId !== null) { + setOpenMenuId(itemId); + } + }, + [openMenuId] + ); + + const handleClose = useCallback(() => { + setOpenMenuId(null); + }, []); + + return ( + + {items.map((item) => ( + handleMenuClick(item.id)} + onMouseEnter={() => handleMenuHover(item.id)} + > + {item.label} + {item.submenu && ( + + {item.submenu.map((subItem) => ( + + ))} + + )} + + ))} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/Panel.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/Panel.tsx new file mode 100644 index 00000000..2ef2bc73 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/Panel.tsx @@ -0,0 +1,129 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +export interface PanelProps { + children: React.ReactNode; + elevation?: "flat" | "raised" | "elevated"; + padding?: "none" | "small" | "medium" | "large"; + fullHeight?: boolean; + className?: string; + style?: React.CSSProperties; +} + +// Transient prop names (prefixed with $) so styled-components filters them +interface StyledProps { + $elevation: "flat" | "raised" | "elevated"; + $padding: "none" | "small" | "medium" | "large"; + $fullHeight?: boolean; +} + +const elevationStyles = { + flat: css` + box-shadow: none; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + `, + raised: css` + box-shadow: ${({ theme }) => theme.shadows.sm}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + `, + elevated: css` + box-shadow: ${({ theme }) => theme.shadows.lg}; + border: none; + `, +}; + +const paddingStyles = { + none: css` + padding: 0; + `, + small: css` + padding: ${({ theme }) => theme.spacing.sm}; + `, + medium: css` + padding: ${({ theme }) => theme.spacing.md}; + `, + large: css` + padding: ${({ theme }) => theme.spacing.lg}; + `, +}; + +const StyledPanel = styled.div.withConfig({ + shouldForwardProp: (prop) => + !["$elevation", "$padding", "$fullHeight"].includes(prop as string), +})` + background-color: ${({ theme }) => theme.colors.background.secondary}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + position: relative; + + ${({ $elevation }) => elevationStyles[$elevation]} + ${({ $padding }) => paddingStyles[$padding]} + ${({ $fullHeight }) => + $fullHeight && + css` + height: 100%; + `} +`; + +export const Panel: React.FC = ({ + children, + elevation = "raised", + padding = "medium", + fullHeight, + ...rest +}) => { + return ( + + {children} + + ); +}; + +// Panel Header component +export interface PanelHeaderProps { + children: React.ReactNode; + actions?: React.ReactNode; + noBorder?: boolean; +} + +export const PanelHeader = styled.div<{ noBorder?: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.lg}; + border-bottom: ${({ theme, noBorder }) => + noBorder ? "none" : `1px solid ${theme.colors.border.light}`}; + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 0; + font-weight: 600; + color: ${({ theme }) => theme.colors.text.primary}; + } +`; + +// Panel Body component +export const PanelBody = styled.div` + padding: ${({ theme }) => theme.spacing.lg}; +`; + +// Panel Footer component +export const PanelFooter = styled.div` + display: flex; + align-items: center; + justify-content: flex-end; + gap: ${({ theme }) => theme.spacing.sm}; + padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.lg}; + border-top: 1px solid ${({ theme }) => theme.colors.border.light}; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + border-bottom-left-radius: ${({ theme }) => theme.borderRadius.md}; + border-bottom-right-radius: ${({ theme }) => theme.borderRadius.md}; +`; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/ProgressBar.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/ProgressBar.tsx new file mode 100644 index 00000000..0e3c4e3f --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/ProgressBar.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import styled, { keyframes } from "styled-components"; + +export interface ProgressBarProps { + /** 0 → 1 (will be clamped automatically). If undefined the bar shows an indeterminate animation. */ + value?: number; + /** Optional descriptive text shown centred below the bar. */ + label?: string; + /** Bar height in pixels (default 8). */ + height?: number; + className?: string; + style?: React.CSSProperties; +} + +const Container = styled.div` + width: 100%; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + overflow: hidden; +`; + +const Filler = styled.div<{ $pct: number }>` + width: ${({ $pct }) => $pct}%; + height: 100%; + background-color: ${({ theme }) => theme.colors.accent.primary}; + transition: width 0.25s ease-out; +`; + +// Simple indeterminate stripes animation +const indeterminate = keyframes` + 0% { left: -40%; width: 40%; } + 50% { left: 20%; width: 60%; } + 100% { left: 100%; width: 40%; } +`; + +const IndeterminateFiller = styled.div` + position: absolute; + top: 0; + bottom: 0; + background-color: ${({ theme }) => theme.colors.accent.primary}; + animation: ${indeterminate} 1.5s infinite ease-in-out; +`; + +const BarWrapper = styled.div<{ $height: number }>` + position: relative; + width: 100%; + height: ${({ $height }) => $height}px; +`; + +export const ProgressBar: React.FC = ({ + value, + label, + height = 8, + className, + style, +}) => { + const pct = Math.max(0, Math.min(1, value ?? 0)) * 100; + const indeterminateMode = value === undefined; + + return ( +
+ + + {indeterminateMode ? : } + + + {label && ( +
+ {label} +
+ )} +
+ ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/ScrollableList.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/ScrollableList.tsx new file mode 100644 index 00000000..7840f1ac --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/ScrollableList.tsx @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +/** + * Simple scroll container used in sidebars and panels where the content can + * grow vertically. It does *not* impose a max-height – consumers should wrap + * it in a flex parent or add their own constraints. + */ +export const ScrollableList = styled.div` + flex: 1 1 auto; + overflow: auto; +`; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/SearchBox.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/SearchBox.tsx new file mode 100644 index 00000000..64e50d1c --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/SearchBox.tsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; + +/** Container for search box consisting of an icon and an input. */ +export const SearchContainer = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + background: ${({ theme }) => theme.colors.background.secondary}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + padding: 4px 8px; + transition: border-color ${({ theme }) => theme.transitions.fast}; + + &:focus-within { + border-color: ${({ theme }) => theme.colors.accent.primary}; + } +`; + +/** Input element used inside SearchContainer */ +export const SearchInput = styled.input` + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: ${({ theme }) => theme.fontSize.caption}; + color: ${({ theme }) => theme.colors.text.primary}; + + &::placeholder { + color: ${({ theme }) => theme.colors.text.tertiary}; + } +`; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/SelectableRow.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/SelectableRow.tsx new file mode 100644 index 00000000..13771a1b --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/SelectableRow.tsx @@ -0,0 +1,23 @@ +import styled, { css } from "styled-components"; + +/** + * Generic selectable row used in list UIs where a checkbox and label (and + * optionally additional content) are shown in a flex row. The `$selected` + * prop controls highlighted colour/weight. + */ +export const SelectableRow = styled.label<{ $selected?: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing.sm}; + padding: 4px 0; + font-size: ${({ theme }) => theme.fontSize.caption}; + cursor: pointer; + + ${({ $selected, theme }) => + $selected && + css` + color: ${theme.colors.accent.primary}; + font-weight: 600; + `} +`; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/SidebarHeader.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/SidebarHeader.tsx new file mode 100644 index 00000000..30a533aa --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/SidebarHeader.tsx @@ -0,0 +1,21 @@ +import styled from "styled-components"; + +/** + * Shared header bar used by sidebar-style panels such as FilterSidebar and + * ColumnManager. It is intentionally minimal – just flex alignment, spacing, + * and theme-aware colours. Consumers should not mutate its layout; instead + * wrap it or compose additional elements inside. + */ +export const SidebarHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing.sm}; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + font-weight: 600; + background: ${({ theme }) => theme.colors.background.tertiary}; + position: sticky; + top: 0; + z-index: 5; +`; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/Spinner.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/Spinner.tsx new file mode 100644 index 00000000..228d5685 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/Spinner.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import styled, { keyframes } from "styled-components"; + +const spin = keyframes` + to { transform: rotate(360deg); } +`; + +const SpinnerWrapper = styled.div<{ size?: number }>` + width: ${({ size = 24 }) => size}px; + height: ${({ size = 24 }) => size}px; + border: 3px solid ${({ theme }) => theme.colors.border.light}; + border-top-color: ${({ theme }) => theme.colors.accent.primary}; + border-radius: 50%; + animation: ${spin} 0.8s linear infinite; +`; + +export const Spinner: React.FC<{ + size?: number; + style?: React.CSSProperties; +}> = ({ size = 24, style }) => { + return ; +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/Toolbar.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/Toolbar.tsx new file mode 100644 index 00000000..5509b5fa --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/Toolbar.tsx @@ -0,0 +1,194 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +export interface ToolbarProps { + children: React.ReactNode; + variant?: "default" | "compact"; + position?: "top" | "bottom"; + className?: string; +} + +export interface ToolbarButtonProps + extends React.ButtonHTMLAttributes { + icon: React.ReactNode; + label?: string; + isActive?: boolean; + showLabel?: boolean; +} + +export interface ToolbarSeparatorProps { + orientation?: "vertical" | "horizontal"; +} + +export interface ToolbarGroupProps { + children: React.ReactNode; + className?: string; +} + +const StyledToolbar = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + padding: ${({ theme, variant }) => + variant === "compact" ? theme.spacing.xs : theme.spacing.sm}; + background-color: ${({ theme }) => theme.colors.background.tertiary}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border.light}; + min-height: ${({ variant }) => (variant === "compact" ? "32px" : "40px")}; + + ${({ position }) => + position === "bottom" && + css` + border-bottom: none; + border-top: 1px solid ${({ theme }) => theme.colors.border.light}; + `} +`; + +const StyledToolbarButton = styled.button<{ + $showLabel?: boolean; + $isActive?: boolean; +}>` + display: inline-flex; + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.spacing.xs}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.sm}; + min-width: ${({ $showLabel }) => ($showLabel ? "auto" : "32px")}; + height: 32px; + background-color: transparent; + border: 1px solid transparent; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + color: ${({ theme }) => theme.colors.text.primary}; + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSize.body}; + cursor: pointer; + transition: all ${({ theme }) => theme.transitions.fast}; + user-select: none; + outline: none; + + &:hover:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.hover}; + border-color: ${({ theme }) => theme.colors.border.light}; + } + + &:active:not(:disabled) { + background-color: ${({ theme }) => theme.colors.background.active}; + border-color: ${({ theme }) => theme.colors.border.medium}; + } + + ${({ $isActive, theme }) => + $isActive && + css` + background-color: ${theme.colors.background.active}; + border-color: ${theme.colors.border.medium}; + + &:hover:not(:disabled) { + background-color: ${theme.colors.background.active}; + border-color: ${theme.colors.border.dark}; + } + `} + + &:focus-visible { + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.accent.primary}; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + /* Ripple effect on click */ + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background-color: rgba(0, 0, 0, 0.1); + transform: translate(-50%, -50%); + transition: width 0.3s, height 0.3s; + } + + &:active::after { + width: 100%; + height: 100%; + } +`; + +const ToolbarIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const ToolbarLabel = styled.span` + white-space: nowrap; +`; + +const StyledToolbarSeparator = styled.div` + ${({ orientation = "vertical", theme }) => + orientation === "vertical" + ? css` + width: 1px; + height: 20px; + background-color: ${theme.colors.border.light}; + margin: 0 ${theme.spacing.xs}; + ` + : css` + width: 100%; + height: 1px; + background-color: ${theme.colors.border.light}; + margin: ${theme.spacing.xs} 0; + `} +`; + +const StyledToolbarGroup = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + + &:not(:last-child) { + margin-right: ${({ theme }) => theme.spacing.sm}; + } +`; + +// Spacer component to push items to the right +export const ToolbarSpacer = styled.div` + flex: 1; +`; + +export const Toolbar: React.FC = ({ children, ...props }) => { + return {children}; +}; + +export const ToolbarButton: React.FC = ({ + children, + icon, + label, + showLabel = false, + isActive = false, + ...rest +}) => { + return ( + + {icon} + {(showLabel || !icon) && label && {label}} + {children} + + ); +}; + +export const ToolbarSeparator: React.FC = (props) => { + return ; +}; + +export const ToolbarGroup: React.FC = ({ + children, + ...props +}) => { + return {children}; +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/TreeView.tsx b/evtx-wasm/evtx-viewer/src/components/Windows/TreeView.tsx new file mode 100644 index 00000000..906ac094 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/TreeView.tsx @@ -0,0 +1,272 @@ +import React, { useState, useCallback } from "react"; +import styled, { css } from "styled-components"; + +export interface TreeNode { + id: string; + label: string; + icon?: React.ReactNode; + /** Optional icon to display when node is expanded (if different). */ + expandedIcon?: React.ReactNode; + children?: TreeNode[]; + isExpanded?: boolean; + isSelected?: boolean; + onClick?: () => void; + /** Arbitrary metadata associated with the node (not used by TreeView itself). */ + data?: unknown; +} + +export interface TreeViewProps { + nodes: TreeNode[]; + onNodeClick?: (node: TreeNode) => void; + /** Fired when a contextmenu (right-click) event occurs on a node. */ + onNodeContextMenu?: (node: TreeNode, event: React.MouseEvent) => void; + onNodeExpand?: (node: TreeNode, isExpanded: boolean) => void; + selectedNodeId?: string; + expandedNodeIds?: Set; + showLines?: boolean; + /** ids of nodes that should be expanded initially (uncontrolled mode) */ + defaultExpanded?: string[]; +} + +const TreeContainer = styled.div` + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSize.body}; + color: ${({ theme }) => theme.colors.text.primary}; + user-select: none; +`; + +const TreeNodeContainer = styled.div<{ $level: number; $showLines?: boolean }>` + position: relative; + + ${({ $level, $showLines, theme }) => + $showLines && + $level > 0 && + css` + &::before { + content: ""; + position: absolute; + left: ${($level - 1) * 20 + 10}px; + top: 0; + bottom: 0; + width: 1px; + background-color: ${theme.colors.border.light}; + } + `} +`; + +const TreeNodeContent = styled.div<{ $isSelected?: boolean; $level: number }>` + display: flex; + align-items: center; + padding: 4px 8px; + padding-left: ${({ $level }) => $level * 20 + 8}px; + cursor: pointer; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + transition: all ${({ theme }) => theme.transitions.fast}; + + &:hover { + background-color: ${({ theme }) => theme.colors.background.hover}; + } + + ${({ $isSelected, $level, theme }) => + $isSelected && + css` + background-color: ${theme.colors.selection.background}; + border: 1px solid ${theme.colors.selection.border}; + padding: 3px 7px; + padding-left: ${$level * 20 + 7}px; + `} +`; + +const ExpandIcon = styled.span<{ $isExpanded: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: 4px; + transition: transform ${({ theme }) => theme.transitions.fast}; + + ${({ $isExpanded }) => + $isExpanded && + css` + transform: rotate(90deg); + `} + + &::before { + content: "▶"; + font-size: 10px; + color: ${({ theme }) => theme.colors.text.secondary}; + } +`; + +const EmptySpace = styled.span` + display: inline-block; + width: 20px; +`; + +const NodeIcon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-right: 6px; + color: ${({ theme }) => theme.colors.text.secondary}; +`; + +const NodeLabel = styled.span` + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +interface TreeNodeComponentProps { + node: TreeNode; + level: number; + onNodeClick?: (node: TreeNode) => void; + /** Fired when a contextmenu (right-click) event occurs on a node. */ + onNodeContextMenu?: (node: TreeNode, event: React.MouseEvent) => void; + onNodeExpand?: (node: TreeNode, isExpanded: boolean) => void; + selectedNodeId?: string; + expandedNodeIds: Set; + showLines?: boolean; +} + +const TreeNodeComponent: React.FC = ({ + node, + level, + onNodeClick, + onNodeContextMenu, + onNodeExpand, + selectedNodeId, + expandedNodeIds, + showLines, +}) => { + const isExpanded = expandedNodeIds.has(node.id); + const isSelected = selectedNodeId === node.id; + const hasChildren = node.children && node.children.length > 0; + + const handleClick = useCallback(() => { + if (node.onClick) { + node.onClick(); + } + if (onNodeClick) { + onNodeClick(node); + } + }, [node, onNodeClick]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + if (onNodeContextMenu) { + onNodeContextMenu(node, e); + } + }, + [node, onNodeContextMenu] + ); + + const handleExpand = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + if (hasChildren && onNodeExpand) { + onNodeExpand(node, !isExpanded); + } + }, + [node, isExpanded, hasChildren, onNodeExpand] + ); + + return ( + + + {hasChildren ? ( + + ) : ( + + )} + {node.icon && ( + + {isExpanded && node.expandedIcon ? node.expandedIcon : node.icon} + + )} + {node.label} + + {hasChildren && isExpanded && ( +
+ {node.children!.map((childNode) => ( + + ))} +
+ )} +
+ ); +}; + +export const TreeView: React.FC = ({ + nodes, + onNodeClick, + onNodeContextMenu, + onNodeExpand, + selectedNodeId, + expandedNodeIds: providedExpandedNodeIds, + showLines = false, + defaultExpanded = [], +}) => { + const [internalExpandedNodeIds, setInternalExpandedNodeIds] = useState< + Set + >(() => new Set(defaultExpanded)); + + const expandedNodeIds = providedExpandedNodeIds || internalExpandedNodeIds; + + const handleNodeExpand = useCallback( + (node: TreeNode, isExpanded: boolean) => { + if (onNodeExpand) { + onNodeExpand(node, isExpanded); + } else { + setInternalExpandedNodeIds((prev) => { + const newSet = new Set(prev); + if (isExpanded) { + newSet.add(node.id); + } else { + newSet.delete(node.id); + } + return newSet; + }); + } + }, + [onNodeExpand] + ); + + return ( + + {nodes.map((node) => ( + + ))} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/components/Windows/index.ts b/evtx-wasm/evtx-viewer/src/components/Windows/index.ts new file mode 100644 index 00000000..bd333e08 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/Windows/index.ts @@ -0,0 +1,34 @@ +export { Button, type ButtonProps } from "./Button"; +export { + Panel, + PanelHeader, + PanelBody, + PanelFooter, + type PanelProps, + type PanelHeaderProps, +} from "./Panel"; +export { TreeView, type TreeViewProps, type TreeNode } from "./TreeView"; +export { + Toolbar, + ToolbarButton, + ToolbarSeparator, + ToolbarGroup, + ToolbarSpacer, + type ToolbarProps, + type ToolbarButtonProps, + type ToolbarSeparatorProps, + type ToolbarGroupProps, +} from "./Toolbar"; +export { MenuBar, type MenuBarProps, type MenuItem } from "./MenuBar"; +export { Dropdown, type DropdownProps, type DropdownOption } from "./Dropdown"; +export { Spinner } from "./Spinner"; +export { + ContextMenu, + type ContextMenuProps, + type ContextMenuItem, +} from "./ContextMenu"; +export { SidebarHeader } from "./SidebarHeader"; +export { SearchContainer, SearchInput } from "./SearchBox"; +export { ScrollableList } from "./ScrollableList"; +export { SelectableRow } from "./SelectableRow"; +export { ProgressBar, type ProgressBarProps } from "./ProgressBar"; diff --git a/evtx-wasm/evtx-viewer/src/components/index.ts b/evtx-wasm/evtx-viewer/src/components/index.ts new file mode 100644 index 00000000..f7ede1de --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/index.ts @@ -0,0 +1,4 @@ +export { LogTableVirtual } from "./LogTableVirtual"; +export { FileTree } from "./FileTree"; +export * from "./Windows"; +export { FilterSidebar } from "./FilterSidebar/FilterSidebar"; diff --git a/evtx-wasm/evtx-viewer/src/components/useRowNavigation.ts b/evtx-wasm/evtx-viewer/src/components/useRowNavigation.ts new file mode 100644 index 00000000..34fb8ecd --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/components/useRowNavigation.ts @@ -0,0 +1,83 @@ +import { useState, useCallback } from "react"; +import type { EvtxRecord } from "../lib/types"; + +interface Params { + totalRows: number; + /** Retrieve record info for a global row index */ + getRowRecord: (idx: number) => EvtxRecord | null; + /** Receives the newly-selected record (optional) */ + onRowSelect?: (rec: EvtxRecord) => void; + /** Scrollable container that hosts the virtualised table */ + scrollContainerRef: React.MutableRefObject; + /** Fixed pixel height of a *single* row */ + rowHeight: number; +} + +export function useRowNavigation({ + totalRows, + getRowRecord, + onRowSelect, + scrollContainerRef, + rowHeight, +}: Params) { + const [selectedRow, setSelectedRow] = useState(null); + + const selectRow = useCallback( + (index: number, ensureVisible = false) => { + setSelectedRow(index); + + const rec = getRowRecord(index); + if (rec && onRowSelect) onRowSelect(rec); + + if (ensureVisible) { + const scrollEl = scrollContainerRef.current; + if (!scrollEl) return; + + const viewportStart = scrollEl.scrollTop; + const viewportEnd = viewportStart + scrollEl.clientHeight; + + const rowTop = index * rowHeight; + const rowBottom = rowTop + rowHeight; + + if (rowTop < viewportStart) { + scrollEl.scrollTop = rowTop; + } else if (rowBottom > viewportEnd) { + scrollEl.scrollTop = rowBottom - scrollEl.clientHeight; + } + } + }, + [getRowRecord, onRowSelect, scrollContainerRef, rowHeight] + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return; + e.preventDefault(); + + if (totalRows === 0) return; + + const currentIndex = selectedRow === null ? -1 : selectedRow; + let newIndex = currentIndex; + + if (e.key === "ArrowDown") { + newIndex = Math.min(totalRows - 1, currentIndex + 1); + } else if (e.key === "ArrowUp") { + newIndex = Math.max(0, currentIndex - 1); + } + + if (newIndex !== currentIndex) { + selectRow(newIndex, true); + } + }, + [selectedRow, totalRows, selectRow] + ); + + const handleRowClick = useCallback( + (idx: number) => { + selectRow(idx, false); + }, + [selectRow] + ); + + return { selectedRow, handleKeyDown, handleRowClick, selectRow } as const; +} diff --git a/evtx-wasm/evtx-viewer/src/hooks/useColumns.ts b/evtx-wasm/evtx-viewer/src/hooks/useColumns.ts new file mode 100644 index 00000000..2a3740a6 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/hooks/useColumns.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import type { TableColumn } from "../lib/types"; +import { useColumnsState, useGlobalDispatch } from "../state/store"; +import { + setColumns as setColumnsAction, + addColumn as addColumnAction, + removeColumn as removeColumnAction, +} from "../state/columns/columnsSlice"; + +/** + * Global reducer-backed columns state. + * Provides the same API shape as React.useState for drop-in replacement. + */ +export function useColumns() { + const columns = useColumnsState(); + const dispatch = useGlobalDispatch(); + + type UpdaterFn = (prev: TableColumn[]) => TableColumn[]; + + const setColumns = useCallback( + (next: TableColumn[] | UpdaterFn) => { + const payload = + typeof next === "function" ? (next as UpdaterFn)(columns) : next; + dispatch(setColumnsAction(payload)); + }, + [dispatch, columns] + ); + + const addColumn = useCallback( + (col: TableColumn) => dispatch(addColumnAction(col)), + [dispatch] + ); + + const removeColumn = useCallback( + (id: string) => dispatch(removeColumnAction(id)), + [dispatch] + ); + + return { columns, setColumns, addColumn, removeColumn } as const; +} diff --git a/evtx-wasm/evtx-viewer/src/hooks/useEvtxIngest.ts b/evtx-wasm/evtx-viewer/src/hooks/useEvtxIngest.ts new file mode 100644 index 00000000..ea8b39f7 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/hooks/useEvtxIngest.ts @@ -0,0 +1,126 @@ +import { useCallback, useRef, useState } from "react"; +import { LazyEvtxReader } from "../lib/lazyReader"; +import { EvtxParser } from "../lib/parser"; +import { DuckDbDataSource } from "../lib/duckDbDataSource"; +import type { EvtxFileInfo, EvtxRecord } from "../lib/types"; +import { + useFiltersState, + useColumnsState, + useGlobalDispatch, +} from "../state/store"; +import { updateEvtxMeta } from "../state/evtx/evtxSlice"; +import { + setIngestProgress as dispatchIngestProgress, + setIngestTotal, + setIngestFileId, +} from "../state/ingest/ingestSlice"; +import { logger } from "../lib/logger"; +import EvtxStorage from "../lib/storage"; +import { startFullIngest } from "../lib/fullIngest"; + +interface UseEvtxIngestReturn { + records: EvtxRecord[]; + parser: EvtxParser | null; + fileInfo: EvtxFileInfo | null; + dataSource: DuckDbDataSource | null; + updateDataSource: (ds: DuckDbDataSource | null) => void; + loadFile: (file: File) => Promise; +} + +export function useEvtxIngest(): UseEvtxIngestReturn { + const filters = useFiltersState(); + const columns = useColumnsState(); + const dispatch = useGlobalDispatch(); + + const [records, setRecords] = useState([]); + const [parser, setParser] = useState(null); + const [fileInfo, setFileInfo] = useState(null); + const [dataSource, setDataSource] = useState(null); + + const ingestAbortRef = useRef(null); + + const loadFile = useCallback( + async (file: File) => { + dispatch(updateEvtxMeta({ matchedCount: 0 })); + dispatch(setIngestTotal(0)); + setDataSource(null); + dispatch( + updateEvtxMeta({ isLoading: true, loadingMessage: "Loading file..." }) + ); + + try { + const reader = await LazyEvtxReader.fromFile(file); + setDataSource(new DuckDbDataSource({}, columns)); + + const initial = await reader.getWindow({ + chunkIndex: 0, + start: 0, + limit: 1000, + }); + setRecords(initial); + dispatch(updateEvtxMeta({ matchedCount: initial.length })); + + const evtxParser = new EvtxParser(); + const info = await evtxParser.parseFile(file); + const storage = await EvtxStorage.getInstance(); + const fileId = await storage.deriveFileId(file); + dispatch(setIngestFileId(fileId)); + dispatch(updateEvtxMeta({ currentFileId: fileId })); + + setFileInfo(info); + // Propagate file info to global state so components like StatusBar can show it + dispatch(updateEvtxMeta({ fileInfo: info })); + setParser(evtxParser); + + ingestAbortRef.current?.abort(); + + void (async () => { + try { + const { clearLogs, countRecords } = await import("../lib/duckdb"); + await clearLogs(); + const ctrl = new AbortController(); + ingestAbortRef.current = ctrl; + dispatch(dispatchIngestProgress(0)); + + await startFullIngest( + reader, + (pct) => dispatch(dispatchIngestProgress(pct)), + { signal: ctrl.signal } + ); + + try { + const total = await countRecords({}); + dispatch(setIngestTotal(total)); + dispatch( + updateEvtxMeta({ totalRecords: total, matchedCount: total }) + ); + } catch (err) { + console.warn("Failed to get totalRecords", err); + } + + setDataSource(new DuckDbDataSource(filters, columns)); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") return; + console.warn("Full ingest failed", e); + dispatch(dispatchIngestProgress(1)); + } + })(); + } catch (err) { + logger.error("Failed to load file via lazy reader", err); + alert("Failed to parse file. Please check if it's a valid EVTX file."); + } finally { + dispatch(updateEvtxMeta({ isLoading: false, loadingMessage: "" })); + } + }, + [columns, filters, dispatch] + ); + + return { + records, + parser, + fileInfo, + dataSource, + updateDataSource: setDataSource, + loadFile, + } as const; +} diff --git a/evtx-wasm/evtx-viewer/src/hooks/useEvtxLog.ts b/evtx-wasm/evtx-viewer/src/hooks/useEvtxLog.ts new file mode 100644 index 00000000..5917d1c8 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/hooks/useEvtxLog.ts @@ -0,0 +1,96 @@ +import { useEffect } from "react"; +import type { EvtxParser } from "../lib/parser"; +import type { DuckDbDataSource } from "../lib/duckDbDataSource"; +import type { EvtxFileInfo, EvtxRecord } from "../lib/types"; +import { + useFiltersState, + useColumnsState, + useGlobalDispatch, + useIngestState, + useEvtxMetaState, +} from "../state/store"; +import { updateEvtxMeta } from "../state/evtx/evtxSlice"; +// ingest slice actions are handled inside useEvtxIngest +import { setActiveColumns } from "../lib/duckdb"; +import { useEvtxIngest } from "./useEvtxIngest"; + +interface UseEvtxLogReturn { + /* state */ + isLoading: boolean; + loadingMessage: string; + records: EvtxRecord[]; + matchedCount: number; + fileInfo: EvtxFileInfo | null; + parser: EvtxParser | null; + dataSource: DuckDbDataSource | null; + totalRecords: number; + currentFileId: string | null; + ingestProgress: number; + /* actions */ + loadFile: (file: File) => Promise; +} + +export function useEvtxLog(): UseEvtxLogReturn { + const filters = useFiltersState(); + const columns = useColumnsState(); + const dispatch = useGlobalDispatch(); + const ingest = useIngestState(); + const evtxMeta = useEvtxMetaState(); + const { + isLoading, + loadingMessage, + matchedCount, + totalRecords: metaTotalRecords, + currentFileId: metaCurrentFileId, + } = evtxMeta; + + const { records, parser, fileInfo, dataSource, updateDataSource, loadFile } = + useEvtxIngest(); + + const { totalRecords, currentFileId, progress: ingestProgress } = ingest; + + // Keep active columns in duckdb helper (unchanged) + useEffect(() => { + setActiveColumns(columns); + }, [columns]); + + // Keep matched count fresh based on DuckDB filters + useEffect(() => { + if (ingestProgress < 1) return; + let active = true; + (async () => { + try { + const { countRecords } = await import("../lib/duckdb"); + const n = await countRecords(filters); + if (active) dispatch(updateEvtxMeta({ matchedCount: n })); + } catch (err) { + console.warn("Failed to count records", err); + } + })(); + return () => { + active = false; + }; + }, [filters, columns, ingestProgress]); + + // Recreate data source whenever filters change (after ingest ready) + useEffect(() => { + if (ingestProgress < 1) return; + import("../lib/duckDbDataSource").then(({ DuckDbDataSource }) => { + updateDataSource(new DuckDbDataSource(filters, columns)); + }); + }, [filters, columns, ingestProgress]); + + return { + isLoading, + loadingMessage, + records, + matchedCount, + fileInfo, + parser, + dataSource, + totalRecords: metaTotalRecords || totalRecords, + currentFileId: metaCurrentFileId || currentFileId, + ingestProgress, + loadFile, + } as const; +} diff --git a/evtx-wasm/evtx-viewer/src/hooks/useFilters.ts b/evtx-wasm/evtx-viewer/src/hooks/useFilters.ts new file mode 100644 index 00000000..7b100ecd --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/hooks/useFilters.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; +import type { FilterOptions } from "../lib/types"; +import { useFiltersState, useGlobalDispatch } from "../state/store"; +import { + setFilters as setFiltersAction, + updateFilters as updateFiltersAction, + clearFilters as clearFiltersAction, +} from "../state/filters/filtersSlice"; + +/** + * Global reducer-backed filters state using central store. + * Mirrors React.useState signature to ease migration. + */ +export function useFilters() { + const filters = useFiltersState(); + const dispatch = useGlobalDispatch(); + + type UpdaterFn = (prev: FilterOptions) => FilterOptions; + + const setFilters = useCallback( + (next: FilterOptions | UpdaterFn) => { + const payload = + typeof next === "function" ? (next as UpdaterFn)(filters) : next; + dispatch(setFiltersAction(payload)); + }, + [dispatch, filters] + ); + + const updateFilters = useCallback( + (patch: Partial) => dispatch(updateFiltersAction(patch)), + [dispatch] + ); + + const clearFilters = useCallback( + () => dispatch(clearFiltersAction()), + [dispatch] + ); + + return { filters, setFilters, updateFilters, clearFilters } as const; +} diff --git a/evtx-wasm/evtx-viewer/src/lib/__tests__/storage.test.ts b/evtx-wasm/evtx-viewer/src/lib/__tests__/storage.test.ts new file mode 100644 index 00000000..8042c270 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/__tests__/storage.test.ts @@ -0,0 +1,38 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll } from "vitest"; + +// Polyfill - attaches IDB* globals automatically +import "fake-indexeddb/auto"; + +import EvtxStorage from "../storage"; + +function makeFakeFile(size = 128 * 1024, name = "sample.evtx"): File { + const content = new Uint8Array(size); + // Fill with deterministic data + for (let i = 0; i < size; i++) content[i] = i % 256; + return new File([content], name); +} + +describe("EvtxStorage", () => { + let storage: EvtxStorage; + + beforeAll(async () => { + storage = await EvtxStorage.getInstance(); + }); + + it("saves file and retrieves metadata", async () => { + const file = makeFakeFile(); + const fileId = await storage.saveFile(file, 2); + const files = await storage.listFiles(); + const meta = files.find((f) => f.fileId === fileId); + expect(meta).toBeDefined(); + expect(meta!.fileSize).toBe(file.size); + }); + + it("gets correct chunk slice", async () => { + const file = makeFakeFile(); + const fileId = await storage.saveFile(file, 2); + const chunk = await storage.getChunk(fileId, 0); + expect(chunk.byteLength).toBe(0x10000); + }); +}); diff --git a/evtx-wasm/evtx-viewer/src/lib/columns.tsx b/evtx-wasm/evtx-viewer/src/lib/columns.tsx new file mode 100644 index 00000000..571c7e1b --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/columns.tsx @@ -0,0 +1,160 @@ +// Column definitions and helpers for LogTableVirtual +// NOTE: This file is .tsx because the column accessors return JSX elements. + +import React from "react"; +import styled from "styled-components"; +import type { TableColumn } from "./types"; +import { + Info20Regular as InfoCircle, + Warning20Regular as Warning, + DismissCircle20Regular as DismissCircle, + ErrorCircle20Regular as ErrorBadge, +} from "@fluentui/react-icons"; + +// --------------------------------------------------------------------------- +// Shared cells & utilities +// --------------------------------------------------------------------------- + +const iconStyle = (color: string) => ({ width: 16, height: 16, color }); + +const LEVEL_ICONS: Record = { + 0: , + 1: , + 2: , + 3: , + 4: , + 5: , +}; + +const LEVEL_NAMES: Record = { + 0: "LogAlways", + 1: "Critical", + 2: "Error", + 3: "Warning", + 4: "Information", + 5: "Verbose", +}; + +const LevelCell = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const formatDateTime = (systemTime?: string): string => { + if (!systemTime) return "-"; + try { + const date = new Date(systemTime); + return date.toLocaleString("en-US", { + month: "2-digit", + day: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + } catch { + return systemTime; + } +}; + +// --------------------------------------------------------------------------- +// Default/system columns +// --------------------------------------------------------------------------- + +export const getDefaultColumns = (): TableColumn[] => [ + { + id: "level", + header: "Level", + sqlExpr: "Level", + accessor: (row) => { + const level = (row["level"] as number | undefined) ?? 4; + return ( + + {LEVEL_ICONS[level] || LEVEL_ICONS[4]} + {LEVEL_NAMES[level]} + + ); + }, + width: 140, + }, + { + id: "time", + header: "Date & Time", + // Our Evtx JSON encoder stores the timestamp under `Event.System.TimeCreated_attributes.SystemTime` + // while others flatten it to Event.System.TimeCreated.SystemTime. + // Use COALESCE so the column works for both shapes. + // Keep as raw string; casting happens in queries that need TIMESTAMP/TIMESTAMPTZ + sqlExpr: `coalesce( + json_extract_string(Raw, '$.Event.System.TimeCreated_attributes.SystemTime'), + json_extract_string(Raw, '$.Event.System.TimeCreated.SystemTime') + )`, + accessor: (row) => { + const sysTime = row["time"] as string | undefined; + return formatDateTime(sysTime); + }, + width: 200, + }, + { + id: "provider", + header: "Source", + sqlExpr: "Provider", + accessor: (row) => String(row["provider"] ?? "-"), + width: 200, + }, + { + id: "eventId", + header: "Event ID", + sqlExpr: "EventID", + accessor: (row) => String(row["eventId"] ?? "-"), + width: 80, + }, + { + id: "task", + header: "Task", + sqlExpr: "json_extract_string(Raw, '$.Event.System.Task')", + accessor: (row) => String(row["task"] ?? "-"), + width: 100, + }, + { + id: "user", + header: "User", + sqlExpr: "json_extract_string(Raw, '$.Event.System.Security.UserID')", + accessor: (row) => String(row["user"] ?? "-"), + width: 140, + }, + { + id: "computer", + header: "Computer", + sqlExpr: "json_extract_string(Raw, '$.Event.System.Computer')", + accessor: (row) => String(row["computer"] ?? "-"), + width: 180, + }, + { + id: "opcode", + header: "OpCode", + sqlExpr: "json_extract_string(Raw, '$.Event.System.Opcode')", + accessor: (row) => String(row["opcode"] ?? "-"), + width: 80, + }, + { + id: "keywords", + header: "Keywords", + sqlExpr: "json_extract_string(Raw, '$.Event.System.Keywords')", + accessor: (row) => String(row["keywords"] ?? "-"), + width: 160, + }, +]; + +// --------------------------------------------------------------------------- +// Dynamic EventData column factory +// --------------------------------------------------------------------------- + +export const buildEventDataColumn = (field: string): TableColumn => ({ + id: `eventData.${field}`, + header: field, + sqlExpr: `json_extract_string(Raw, '$.Event.EventData.${field}')`, + accessor: (row) => String(row[`eventData.${field}`] ?? "-"), + width: 200, +}); diff --git a/evtx-wasm/evtx-viewer/src/lib/computeSliceRows.ts b/evtx-wasm/evtx-viewer/src/lib/computeSliceRows.ts new file mode 100644 index 00000000..4be6a1c3 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/computeSliceRows.ts @@ -0,0 +1,71 @@ +export interface SliceConfig { + viewportStart: number; + viewportHeight: number; + chunkTop: number; + chunkHeight: number; + rowHeight: number; + bufferRows: number; + maxRows: number; + recordCount: number; +} + +/** + * Compute the [startRow, endRow] (inclusive) within a chunk that intersect the + * viewport plus buffer. Returns `null` if the chunk is entirely outside the + * buffered viewport. + */ +export function computeSliceRows(cfg: SliceConfig): [number, number] | null { + const { + viewportStart, + viewportHeight, + chunkTop, + chunkHeight, + rowHeight, + bufferRows, + maxRows, + recordCount, + } = cfg; + + const bufferPx = bufferRows * rowHeight; + const viewportEnd = viewportStart + viewportHeight; + + const chunkBottom = chunkTop + chunkHeight; + + // Entire chunk outside buffered viewport + if ( + viewportEnd + bufferPx <= chunkTop || + viewportStart - bufferPx >= chunkBottom + ) { + return null; + } + + // Intersection bounds in pixels within chunk + const intersectTopPx = + Math.max(viewportStart - bufferPx, chunkTop) - chunkTop; + const intersectBottomPx = + Math.min(viewportEnd + bufferPx, chunkBottom) - chunkTop; + + // No intersection if bottom is above top + if (intersectBottomPx <= 0 || intersectTopPx >= chunkHeight) { + return null; + } + + let startRow = Math.floor(intersectTopPx / rowHeight); + let endRow = Math.ceil(intersectBottomPx / rowHeight) - 1; // inclusive + + // Clamp to valid record indices + startRow = Math.min(Math.max(0, startRow), recordCount - 1); + endRow = Math.min(recordCount - 1, Math.max(startRow, endRow)); + + // Enforce max rows window + if (endRow - startRow + 1 > maxRows) { + endRow = startRow + maxRows - 1; + } + + // If after clamping we ended with an empty range, skip rendering + if (startRow > endRow) { + return null; + } + + return [startRow, endRow]; +} diff --git a/evtx-wasm/evtx-viewer/src/lib/duckDbDataSource.ts b/evtx-wasm/evtx-viewer/src/lib/duckDbDataSource.ts new file mode 100644 index 00000000..5d0ae690 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/duckDbDataSource.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// DuckDbDataSource.ts – virtual-table backend that pages rows directly from +// DuckDB based on current filters. + +import type { FilterOptions, ColumnSpec } from "./types"; +import { countRecords, fetchTabular } from "./duckdb"; + +/** + * Page-oriented data source for use with `useChunkVirtualizer`. It splits the + * full result set into fixed-size "chunks" (pages). Each chunk is fetched on + * demand from DuckDB using LIMIT/OFFSET so filtering is done entirely in SQL. + */ +export class DuckDbDataSource { + private readonly filters: FilterOptions; + private readonly columns: ColumnSpec[]; + private readonly PAGE = 4000; // match old chunk size + private totalRecords: number | null = null; + + constructor(filters: FilterOptions, columns: ColumnSpec[]) { + this.filters = filters; + this.columns = columns; + } + + /** Prepare row count so virtualiser knows total height */ + async init(): Promise { + if (this.totalRecords !== null) return; // already ready + this.totalRecords = await countRecords(this.filters); + } + + /** Number of logical chunks */ + getChunkCount(): number { + if (this.totalRecords === null) return 0; + return Math.ceil(this.totalRecords / this.PAGE); + } + + /** Per-chunk header counts used by virtualiser’s initial estimate */ + getChunkRecordCounts(): number[] { + const cnt = this.getChunkCount(); + if (cnt === 0 || this.totalRecords === null) return []; + const arr = new Array(cnt).fill(this.PAGE); + // Adjust last chunk to remaining rows + const fullBeforeLast = (cnt - 1) * this.PAGE; + arr[cnt - 1] = this.totalRecords - fullBeforeLast; + return arr; + } + + /** Exact record count for a specific chunk (unknown until init) */ + // Provided for structural compatibility with ChunkDataSource – returns + // PAGE for all but the last chunk (after init). Callers usually only need + // getChunkRecordCounts() but some may call this for convenience. + getChunkRecordCount(idx: number): number | undefined { + const counts = this.getChunkRecordCounts(); + return counts[idx]; + } + + /** Absolute total #records across all chunks */ + getTotalRecords(): number { + return this.totalRecords ?? 0; + } + + /** Fetch one page */ + async getChunk(idx: number): Promise { + // Changed EvtxRecord[] to any[] as EvtxRecord is no longer imported + const offset = idx * this.PAGE; + return fetchTabular(this.columns, this.filters, this.PAGE, offset); + } +} diff --git a/evtx-wasm/evtx-viewer/src/lib/duckdb.ts b/evtx-wasm/evtx-viewer/src/lib/duckdb.ts new file mode 100644 index 00000000..f6b6bb94 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/duckdb.ts @@ -0,0 +1,485 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as duckdb from "@duckdb/duckdb-wasm"; +import type { EvtxRecord, FilterOptions, BucketCounts } from "./types"; + +// ------------- File-session tracking ------------- +// Each time we load a new log file we bump this counter. Background inserts +// from previous sessions check the value and bail out early to avoid mixing +// data from multiple files. +let activeSessionId = 0; + +export function beginNewSession(): number { + return ++activeSessionId; +} + +function isStale(sessionAtCall: number): boolean { + return sessionAtCall !== activeSessionId; +} + +// Keep a singleton instance so multiple components share the same DB +let db: duckdb.AsyncDuckDB | null = null; +let initPromise: Promise | null = null; +let conn: any = null; + +/** + * Initialise DuckDB-WASM. Call once on application startup. + */ +export async function initDuckDB(): Promise { + if (conn) return conn; + if (initPromise) return initPromise; + + initPromise = (async () => { + // Select the best JSDelivr bundle for the current browser + const JSDELIVR_BUNDLES = duckdb.getJsDelivrBundles(); + const bundle = await duckdb.selectBundle(JSDELIVR_BUNDLES); + + // Create a same-origin wrapper worker to bypass cross-origin script restrictions. + const workerBlobUrl = URL.createObjectURL( + new Blob([`importScripts("${bundle.mainWorker}");`], { + type: "text/javascript", + }) + ); + + const worker = new Worker(workerBlobUrl); // classic worker + + const logger = new duckdb.ConsoleLogger(); + db = new duckdb.AsyncDuckDB(logger, worker); + await db.instantiate(bundle.mainModule, bundle.pthreadWorker); + + URL.revokeObjectURL(workerBlobUrl); + + conn = await db.connect(); + + // Make sure a table exists – schema will be created automatically on first insert + await conn.query( + `CREATE TABLE IF NOT EXISTS logs ( + EventID INTEGER, + Level INTEGER, + Provider TEXT, + Channel TEXT, + Raw TEXT + );` + ); + return conn; + })(); + + return initPromise; +} + +// (Legacy ingestRecords and insertArrowBatch functions removed – Arrow IPC is now the sole ingestion path.) + +export async function insertArrowIPC( + buffer: Uint8Array | ArrayBuffer +): Promise { + const session = activeSessionId; + const conn = await initDuckDB(); + if (isStale(session)) return; + + // Prefer new API name first (DuckDB ≥1.3 / wasm docs): + // The `create` flag must be disabled here because we already create the + // `logs` table (or ensure it exists) during `initDuckDB()`. Leaving the + // default (`create: true`) causes DuckDB to try to issue another + // CREATE TABLE logs (...) + // for every batch which fails after the first batch with + // "Table with name \"logs\" already exists!". + // See ArrowInsertOptions in duckdb-wasm docs. + const insertOpts = { name: "logs", append: true, create: false } as const; + conn.insertArrowFromIPCStream(buffer, insertOpts); +} + +function escapeSqlString(str: string): string { + return str.replace(/'/g, "''"); +} + +/** Build a SQL WHERE clause from current filters */ +export function buildWhere(filters: FilterOptions): string { + const clauses: string[] = []; + + if (filters.provider && filters.provider.length) { + const list = filters.provider + .map((p) => `'${escapeSqlString(p)}'`) + .join(","); + clauses.push(`Provider IN (${list})`); + } + + if (filters.channel && filters.channel.length) { + const list = filters.channel + .map((c) => `'${escapeSqlString(c)}'`) + .join(","); + clauses.push(`Channel IN (${list})`); + } + + if (filters.level && filters.level.length) { + const list = filters.level.join(","); + clauses.push(`Level IN (${list})`); + } + + if (filters.eventId && filters.eventId.length) { + const list = filters.eventId.join(","); + clauses.push(`EventID IN (${list})`); + } + + // New: EventData JSON field filters. Each entry is AND-ed with the rest of + // the WHERE clauses. For a field “SubjectUserSid” with values ["S-1-5-18"], + // we emit: + // json_extract_string(Raw, '$.Event.EventData.SubjectUserSid') IN ('S-1-5-18') + if (filters.eventData) { + for (const [field, values] of Object.entries(filters.eventData)) { + if (!values || values.length === 0) continue; + const valueList = values + .map((v) => `'${escapeSqlString(String(v))}'`) + .join(","); + // Use DuckDB’s json_extract_string to pull the scalar value. + const path = `$.Event.EventData.${field}`; + clauses.push(`json_extract_string(Raw, '${path}') IN (${valueList})`); + } + } + + // EventData exclusion + if (filters.eventDataExclude) { + for (const [field, values] of Object.entries(filters.eventDataExclude)) { + if (!values || values.length === 0) continue; + const valueList = values + .map((v) => `'${escapeSqlString(String(v))}'`) + .join(","); + const path = `$.Event.EventData.${field}`; + clauses.push(`json_extract_string(Raw, '${path}') NOT IN (${valueList})`); + } + } + + if (filters.searchTerm && filters.searchTerm.trim() !== "") { + const pattern = `%${escapeSqlString(filters.searchTerm.toLowerCase())}%`; + clauses.push( + `(lower(Provider) LIKE '${pattern}' OR lower(Channel) LIKE '${pattern}' OR cast(EventID as TEXT) LIKE '${pattern}')` + ); + } + + // TODO: timeRange filter if needed + + // Generic column equality filters + if (filters.columnEquals) { + for (const [colId, values] of Object.entries(filters.columnEquals)) { + if (!values || values.length === 0) continue; + const colSpec = activeColumns.find((c) => c.id === colId); + if (!colSpec) continue; + // Special case – time column equality matches on truncated timestamp + if (colId === "time" && lastTimeFacetUnit) { + const intervalLitMap: Record = { + minute: "INTERVAL '1 minute'", + hour: "INTERVAL '1 hour'", + day: "INTERVAL '1 day'", + week: "INTERVAL '1 week'", + month: "INTERVAL '1 month'", + } as const; + + const intervalLit = + intervalLitMap[lastTimeFacetUnit] ?? "INTERVAL '1 hour'"; + + const valueList = values + .map((v) => `'${escapeSqlString(String(v))}'`) + .join(","); + clauses.push( + `time_bucket(${intervalLit}, CAST(${colSpec.sqlExpr} AS TIMESTAMPTZ)) IN (TIMESTAMP ${valueList})` + ); + continue; + } + const valueList = values + .map((v) => `'${escapeSqlString(String(v))}'`) + .join(","); + clauses.push(`${colSpec.sqlExpr} IN (${valueList})`); + } + } + + return clauses.length ? clauses.join(" AND ") : ""; +} + +/** + * Fetch aggregated facet counts given current filters. + * Returns the counts for all Level/Provider/Channel/EventID values that still match. + */ +export async function getFacetCounts( + filters: FilterOptions +): Promise { + const c = await initDuckDB(); + + const facetQueries: Record = { + level: "Level", + provider: "Provider", + channel: "Channel", + event_id: "EventID", + } as const; + + const result: BucketCounts = { + level: {}, + provider: {}, + channel: {}, + event_id: {}, + }; + + // Run queries sequentially – could be parallelised but fine for <100 facets + for (const [bucketKey, col] of Object.entries(facetQueries) as [ + keyof BucketCounts, + string + ][]) { + // For the EventID facet we want to ignore the current EventID filter so + // that all IDs remain visible for multi-selection. + let filtersForFacet: FilterOptions = filters; + if (bucketKey === "event_id") { + filtersForFacet = { ...filters, eventId: [] }; + } + + const whereFacet = buildWhere(filtersForFacet); + const whereFacetSql = whereFacet ? `WHERE ${whereFacet}` : ""; + + const res = await c.query( + `SELECT ${col} as key, count(*) as cnt FROM logs ${whereFacetSql} GROUP BY ${col}` + ); + + // DuckDB may return bigint values for both the grouping key and the count + // column. Convert them to primitive JS numbers/strings so downstream code + // can safely do arithmetic like `value + 1` without hitting the + // "Cannot mix BigInt and other types" TypeError. + for (const row of res.toArray() as { key: unknown; cnt: unknown }[]) { + // Normalise the group key. For numeric columns DuckDB can return a + // BigInt – stringify first and then cast where appropriate so we keep + // leading zeros etc. for text columns unchanged. + const k = row.key === null ? "" : String(row.key); + + // The aggregate count is always numeric. Convert BigInt → number; leave + // plain numbers untouched. Values here are expected to be < 2^53 which + // is safe for JS Number. + const cntNum: number = + typeof row.cnt === "bigint" ? Number(row.cnt) : (row.cnt as number); + + (result[bucketKey] as Record)[k] = cntNum; + } + } + + return result; +} + +/** + * Fetch paginated records matching the filters. + */ +export async function fetchRecords( + filters: FilterOptions, + limit = 100, + offset = 0 +): Promise { + const c = await initDuckDB(); + + const where = buildWhere(filters); + const whereSql = where ? `WHERE ${where}` : ""; + + const res = await c.query( + `SELECT Raw FROM logs ${whereSql} LIMIT ${limit} OFFSET ${offset}` + ); + + const out: EvtxRecord[] = []; + for (const row of res.toArray() as { Raw: string }[]) { + try { + out.push(JSON.parse(row.Raw)); + } catch { + /* ignore malformed */ + } + } + return out; +} + +/** Remove all rows from the logs table – used when loading a new file. */ +export async function clearLogs(): Promise { + const c = await initDuckDB(); + try { + beginNewSession(); + await c.query("DELETE FROM logs"); + } catch (err) { + // If the table somehow doesn’t exist yet just ignore. + console.warn("DuckDB clearLogs failed", err); + } +} + +/** Count records matching current filters (fast aggregate). */ +export async function countRecords(filters: FilterOptions): Promise { + const c = await initDuckDB(); + const where = buildWhere(filters); + const whereSql = where ? `WHERE ${where}` : ""; + const res = await c.query(`SELECT count(*) as cnt FROM logs ${whereSql}`); + const row = res.toArray()[0] as { cnt: number | bigint } | undefined; + if (!row) return 0; + return typeof row.cnt === "bigint" ? Number(row.cnt) : (row.cnt as number); +} + +// --------------------------------------------------------------------------- +// Generic tabular fetch based on dynamic column specs +// --------------------------------------------------------------------------- + +import type { ColumnSpec } from "./types"; + +// ----------------------------------------------------------- +// Active column registry – set by UI layer so buildWhere can +// translate column IDs to SQL expressions without threading +// `columns` param everywhere. +// ----------------------------------------------------------- + +let activeColumns: ColumnSpec[] = []; + +export function setActiveColumns(cols: ColumnSpec[]): void { + activeColumns = cols; +} + +/** + * Fetch rows as plain objects according to the provided columns list. + * Each ColumnSpec.sqlExpr MUST already alias to its id (for example + * `Level AS level`). For convenience we still add the alias automatically + * if not present. + */ +export async function fetchTabular( + columns: ColumnSpec[], + filters: FilterOptions, + limit = 100, + offset = 0 +): Promise[]> { + const c = await initDuckDB(); + + const selectFragments = columns.map((col) => { + // Simple heuristic – if the sqlExpr already contains an " AS " use as-is + if (/\sas\s/i.test(col.sqlExpr)) return col.sqlExpr; + return `${col.sqlExpr} AS "${col.id}"`; + }); + + // Always include Raw so we can reconstruct full event if needed + if (!columns.some((c) => c.id === "Raw")) { + selectFragments.push("Raw"); + } + + const where = buildWhere(filters); + const whereSql = where ? `WHERE ${where}` : ""; + + const query = `SELECT ${selectFragments.join( + ", " + )} FROM logs ${whereSql} LIMIT ${limit} OFFSET ${offset}`; + const res = await c.query(query); + return res.toArray() as Record[]; +} + +// --------------------------------------------------------------------------- +// Facet counts for arbitrary column (for header filter popover) +// --------------------------------------------------------------------------- + +let lastTimeFacetUnit: "minute" | "hour" | "day" | "week" | "month" | null = + null; + +/** + * Adaptive time-bucket facet helper. + * + * Groups the timestamp column into a sensible resolution (minute/hour/day…) + * based on the span of the data *after* filters are applied. The heavy + * lifting happens entirely in DuckDB via a CTE chain using `date_bin`. + */ +async function getTimeFacetBuckets( + col: ColumnSpec, + filters: FilterOptions, + limit = 250 +): Promise<{ v: unknown; c: number }[]> { + const c = await initDuckDB(); + + // Ensure timestamps are typed (timezone-aware). + const tsExpr = `CAST(${col.sqlExpr} AS TIMESTAMPTZ)`; + + // Remove equality filter on this column if present. + const adjusted: FilterOptions = { + ...filters, + columnEquals: { ...filters.columnEquals, [col.id]: [] }, + }; + + const where = buildWhere(adjusted); + const whereSql = where ? `WHERE ${where}` : ""; + + // Single-shot query: decide bucket width and aggregate in one SQL call. + const singleSql = ` +WITH stats AS ( + SELECT + min(${tsExpr}) AS min_t, + max(${tsExpr}) AS max_t, + datediff('millisecond', min(${tsExpr}), max(${tsExpr})) AS span_ms + FROM logs + ${whereSql} +), +params AS ( + SELECT + CASE + WHEN span_ms <= 7200000 THEN INTERVAL '1 minute' + WHEN span_ms <= 172800000 THEN INTERVAL '1 hour' + WHEN span_ms <= 7776000000 THEN INTERVAL '1 day' + WHEN span_ms <= 31536000000 THEN INTERVAL '1 week' + ELSE INTERVAL '1 month' + END AS bucket_width, + CASE + WHEN span_ms <= 7200000 THEN 'minute' + WHEN span_ms <= 172800000 THEN 'hour' + WHEN span_ms <= 7776000000 THEN 'day' + WHEN span_ms <= 31536000000 THEN 'week' + ELSE 'month' + END AS bucket_unit + FROM stats +), +buckets AS ( + SELECT + strftime(time_bucket(p.bucket_width, ${tsExpr}), '%Y-%m-%d %H:%M') AS v, + count(*) AS c, + p.bucket_unit AS bucket_unit + FROM logs + CROSS JOIN params p + ${whereSql} + GROUP BY v, p.bucket_unit + ORDER BY v + LIMIT ${limit} +) +SELECT v, c, bucket_unit FROM buckets;`; + + const res = await c.query(singleSql); + const rows = res.toArray() as { + v: unknown; + c: number; + bucket_unit: string; + }[]; + + if (rows.length > 0) { + const unitStr = rows[0].bucket_unit as + | "minute" + | "hour" + | "day" + | "week" + | "month"; + lastTimeFacetUnit = unitStr; + } + + // Strip bucket_unit before returning. + return rows.map(({ v, c }) => ({ v, c })); +} + +export async function getColumnFacetCounts( + col: ColumnSpec, + filters: FilterOptions, + limit = 250 +): Promise<{ v: unknown; c: number }[]> { + if (col.id === "time") { + return getTimeFacetBuckets(col, filters, limit); + } + + const c = await initDuckDB(); + // Exclude current equality filter on this column when computing counts so + // user can multi-select. + const adjusted: FilterOptions = { + ...filters, + columnEquals: { ...filters.columnEquals, [col.id]: [] }, + }; + + const where = buildWhere(adjusted); + const whereSql = where ? `WHERE ${where}` : ""; + const sql = `SELECT ${col.sqlExpr} AS v, count(*) c FROM logs ${whereSql} GROUP BY v ORDER BY c DESC LIMIT ${limit}`; + const res = await c.query(sql); + return res.toArray() as { v: unknown; c: number }[]; +} + +// (end of duckdb helpers) diff --git a/evtx-wasm/evtx-viewer/src/lib/fullIngest.ts b/evtx-wasm/evtx-viewer/src/lib/fullIngest.ts new file mode 100644 index 00000000..ef809f0a --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/fullIngest.ts @@ -0,0 +1,52 @@ +import type { LazyEvtxReader } from "./lazyReader"; +import { insertArrowIPC, initDuckDB } from "../lib/duckdb"; + +export interface FullIngestOptions { + /** AbortSignal to cancel an in-flight ingest when a new file is opened */ + signal?: AbortSignal; + /** Size of each Arrow batch – default 10 000 */ + batchSize?: number; +} + +export type IngestProgressCallback = (pct: number) => void; + +/** + * Stream the entire EVTX file via LazyEvtxReader into DuckDB using Arrow batches. + * Progress is reported as fraction [0,1]. + */ +export async function startFullIngest( + reader: LazyEvtxReader, + onProgress?: IngestProgressCallback, + opts: FullIngestOptions = {} +): Promise { + const { signal } = opts; + const { totalChunks } = await reader.getFileInfo(); + + await initDuckDB(); + + for (let chunkIdx = 0; chunkIdx < totalChunks; chunkIdx++) { + if (signal?.aborted) return; + // Retrieve Arrow IPC for the whole chunk from Rust/WASM + const { buffer } = await reader.getArrowIPCChunk(chunkIdx); + + // Insert into DuckDB in one go – DuckDB handles chunking internally. + if (signal?.aborted) return; + await insertArrowIPC(buffer); + + if (onProgress) { + const pct = (chunkIdx + 1) / totalChunks; + const clamped = Math.min(1, pct); + console.debug(`Full ingest progress: ${(clamped * 100).toFixed(2)}%`); + onProgress(clamped); + } + + // allow UI thread to breathe between chunks + await new Promise((r) => requestAnimationFrame(() => r(null))); + } + + if (onProgress) { + // eslint-disable-next-line no-console + console.debug("Full ingest progress: 100% (complete)"); + onProgress(1); + } +} diff --git a/evtx-wasm/evtx-viewer/src/lib/lazyReader.ts b/evtx-wasm/evtx-viewer/src/lib/lazyReader.ts new file mode 100644 index 00000000..c7ac02b7 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/lazyReader.ts @@ -0,0 +1,146 @@ +import type { EvtxRecord, ParseResult } from "./types"; +import { logger } from "./logger"; + +// Static type-only import of the wasm-bindgen generated module. This file is +// generated by `wasm-pack` into `src/wasm/evtx_wasm.d.ts` via the dev script. +// At runtime we will still use the JS glue file but we cast it to this type so +// TypeScript recognises the methods (e.g., `parse_chunk_records`). +type WasmBindings = typeof import("../wasm/evtx_wasm"); + +export interface ChunkWindow { + chunkIndex: number; + start: number; // record offset within chunk + limit: number; // max records to retrieve (<= 0 means no limit) +} + +export class LazyEvtxReader { + private wasmModule: WasmBindings | null = null; + private parser!: InstanceType; + private cache: Map = new Map(); + + private async init(data: Uint8Array): Promise { + if (!this.wasmModule) { + // Dynamically load the JS glue file and cast to the static typings. + this.wasmModule = (await import( + "../wasm/evtx_wasm.js" + )) as unknown as WasmBindings; + } + this.parser = new this.wasmModule.EvtxWasmParser(data); + } + + /** Factory helper that reads a File into memory and constructs a reader. */ + static async fromFile(file: File): Promise { + const buf = new Uint8Array(await file.arrayBuffer()); + const reader = new LazyEvtxReader(); + await reader.init(buf); + return reader; + } + + private cacheKey(chunk: number, start: number, limit: number): string { + return `${chunk}-${start}-${limit}`; + } + + /** + * Recursively convert Map instances (returned by `serde_wasm_bindgen`) into + * plain JavaScript objects so consumers can use regular property access. + */ + private static mapToObject(input: unknown): unknown { + if (input instanceof Map) { + const out: Record = {}; + input.forEach((v, k) => { + out[k as string] = LazyEvtxReader.mapToObject(v); + }); + return out; + } + if (Array.isArray(input)) + return input.map((v) => LazyEvtxReader.mapToObject(v)); + return input; + } + + /** Retrieve records for the given window (cached). */ + async getWindow(win: ChunkWindow): Promise { + const key = this.cacheKey(win.chunkIndex, win.start, win.limit); + const cached = this.cache.get(key); + if (cached) return cached; + + const res: ParseResult = this.parser.parse_chunk_records( + win.chunkIndex, + win.start, + win.limit > 0 ? win.limit : undefined + ) as unknown as ParseResult; + + // Convert each record from potential Map/Array hybrids into plain objects. + const records = (res.records as unknown[]).map((r) => + LazyEvtxReader.mapToObject(r) + ) as EvtxRecord[]; + + logger.debug(`LazyEvtxReader fetched ${records.length} recs`, { + window: win, + }); + + this.cache.set(key, records); + return records; + } + + /** Retrieve high-level information about the EVTX file (chunk counts, etc.). */ + async getFileInfo(): Promise<{ + totalChunks: number; + chunkRecordCounts: number[]; + }> { + if (!this.parser) { + throw new Error("Parser not initialised – call fromFile() first"); + } + + // The Rust binding returns `FileInfo` with snake_case keys; we only need + // `total_chunks` and the per-chunk `record_count` values to compute + // record offsets. We decode and massage them into simple JS primitives. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const raw = this.parser.get_file_info() as unknown as { + total_chunks: number; + chunks: { record_count: string }[]; + }; + + const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER); + const chunkRecordCounts = raw.chunks.map((c, idx) => { + try { + const big = BigInt(c.record_count); + if (big <= MAX_SAFE) return Number(big); + + logger.info("Chunk record_count exceeds 53-bit, clamping", { + idx, + raw: c.record_count, + }); + return Number.MAX_SAFE_INTEGER; // upper bound so downstream maths stays finite + } catch { + logger.error("Failed to parse chunk record_count as BigInt", { + idx, + raw: c.record_count, + }); + return 0; + } + }); + return { + totalChunks: raw.total_chunks, + chunkRecordCounts, + }; + } + + /** Retrieve Arrow IPC buffer + row count for a chunk. */ + async getArrowIPCChunk( + chunkIndex: number + ): Promise<{ buffer: Uint8Array; rows: number }> { + if (!this.parser) { + throw new Error("Parser not initialised – call fromFile() first"); + } + // Invoke the WASM-exported method directly on the parser instance so that + // the implicit `this` binding is preserved (required under strict mode). + const rawRes: unknown = ( + this.parser as unknown as { + chunk_arrow_ipc: (idx: number) => unknown; + } + ).chunk_arrow_ipc(chunkIndex); + + const res = rawRes as { ipc: Uint8Array; rows: number }; + return { buffer: res.ipc, rows: res.rows }; + } +} diff --git a/evtx-wasm/evtx-viewer/src/lib/logger.ts b/evtx-wasm/evtx-viewer/src/lib/logger.ts new file mode 100644 index 00000000..55741a9a --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/logger.ts @@ -0,0 +1,116 @@ +// Comprehensive logging system +export enum LogLevel { + DEBUG = 0, + INFO = 1, + WARN = 2, + ERROR = 3, +} + +export interface LogEntry { + timestamp: Date; + level: LogLevel; + message: string; + context?: unknown; +} + +class Logger { + private static instance: Logger; + private logLevel: LogLevel = LogLevel.INFO; + private logs: LogEntry[] = []; + private readonly maxLogs = 1000; + private listeners: Array<(log: LogEntry) => void> = []; + + private constructor() { + // Set log level from localStorage or default + const savedLevel = localStorage.getItem("evtx-viewer-log-level"); + if (savedLevel) { + this.logLevel = parseInt(savedLevel, 10); + } + } + + static getInstance(): Logger { + if (!Logger.instance) { + Logger.instance = new Logger(); + } + return Logger.instance; + } + + setLogLevel(level: LogLevel): void { + this.logLevel = level; + localStorage.setItem("evtx-viewer-log-level", level.toString()); + } + + getLogLevel(): LogLevel { + return this.logLevel; + } + + getLogs(): LogEntry[] { + return [...this.logs]; + } + + clearLogs(): void { + this.logs = []; + } + + subscribe(listener: (log: LogEntry) => void): () => void { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter((l) => l !== listener); + }; + } + + private log(level: LogLevel, message: string, context?: unknown): void { + if (level < this.logLevel) return; + + const entry: LogEntry = { + timestamp: new Date(), + level, + message, + context, + }; + + // Add to internal log buffer + this.logs.push(entry); + if (this.logs.length > this.maxLogs) { + this.logs.shift(); + } + + // Notify listeners + this.listeners.forEach((listener) => listener(entry)); + + // Console output + const logMethod = + level === LogLevel.ERROR + ? "error" + : level === LogLevel.WARN + ? "warn" + : level === LogLevel.INFO + ? "info" + : "log"; + + const prefix = `[${LogLevel[level]}] ${entry.timestamp.toISOString()}`; + if (context !== undefined) { + console[logMethod](prefix, message, context); + } else { + console[logMethod](prefix, message); + } + } + + debug(message: string, context?: unknown): void { + this.log(LogLevel.DEBUG, message, context); + } + + info(message: string, context?: unknown): void { + this.log(LogLevel.INFO, message, context); + } + + warn(message: string, context?: unknown): void { + this.log(LogLevel.WARN, message, context); + } + + error(message: string, context?: unknown): void { + this.log(LogLevel.ERROR, message, context); + } +} + +export const logger = Logger.getInstance(); diff --git a/evtx-wasm/evtx-viewer/src/lib/parser.ts b/evtx-wasm/evtx-viewer/src/lib/parser.ts new file mode 100644 index 00000000..24ed5386 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/parser.ts @@ -0,0 +1,206 @@ +// EVTX Parser interface - wraps the WASM module +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { EvtxFileInfo, ParseResult, EvtxRecord } from "./types"; +import EvtxStorage from "./storage"; + +// Minimal runtime shape of the WASM parser instance. We only include the +// methods we actually call from the TypeScript side. +interface WasmParserInstance { + parse_all(): unknown; + parse_with_limit(limit?: number): unknown; + parse_chunk(chunkIndex: number): unknown; + get_record_by_id(recordId: number): unknown; +} + +export interface IEvtxParser { + parseFile(file: File): Promise; + parseAllRecords(): Promise; + parseChunk(chunkIndex: number): Promise; + parseWithLimit(limit: number): Promise; + getRecordById(recordId: number): Promise; + exportRecords(records: EvtxRecord[], format: "json" | "xml"): string; +} + +export class EvtxParser implements IEvtxParser { + private wasmParser: WasmParserInstance | null = null; + private fileData: Uint8Array | null = null; + + /** + * Normalise the raw `ParseResult` returned from the WASM bindings to the + * camelCase `ParseResult` expected by the rest of the TypeScript codebase. + */ + private normaliseParseResult(raw: unknown): ParseResult { + const obj = raw as Record; + + // Helper to deeply convert Map instances produced by `serde_wasm_bindgen` + // into plain JavaScript objects so React/TS accessors work as expected. + const mapToObject = (input: unknown): unknown => { + if (input instanceof Map) { + const out: Record = {}; + input.forEach((v, k) => { + out[k as string] = mapToObject(v); + }); + return out; + } + if (Array.isArray(input)) { + return input.map((el) => mapToObject(el)); + } + return input; + }; + + // Ensure `records` is an array of objects (may arrive as JSON strings). + const records: EvtxRecord[] = ((obj.records as unknown[]) ?? []).map( + (r: unknown) => { + if (typeof r === "string") { + try { + return JSON.parse(r) as EvtxRecord; + } catch { + // Fallback – an unparsable string. Return a placeholder to avoid crashing. + return { Event: { System: {} } } as unknown as EvtxRecord; + } + } + + // Convert Map → object recursively if needed + const transformed = mapToObject(r) as EvtxRecord; + + // --------------------------------------------------------------- + // Normalise Provider name so downstream code can simply access + // `sys.Provider?.Name` without worrying about the nested + // `#attributes` object that the Rust → WASM JSON sometimes emits. + // --------------------------------------------------------------- + try { + // Many records have shape: + // Provider: { "#attributes": { Name: "Foo", Guid: "..." } } + // Copy over the embedded Name to a flat field if missing. + const sys: any = transformed?.Event?.System ?? {}; + if (sys.Provider && typeof sys.Provider === "object") { + const prov: any = sys.Provider; + const attrs: any = prov["#attributes"]; + if (attrs && attrs.Name && prov.Name === undefined) { + prov.Name = attrs.Name; + } + + // Expose the attributes object under a predictable property so + // that existing fallbacks `Provider_attributes?.Name` still work. + if (attrs && sys.Provider_attributes === undefined) { + sys.Provider_attributes = attrs; + } + } + } catch { + /* ignore – best-effort normalisation */ + } + + return transformed; + } + ); + + return { + records, + totalRecords: + (obj.total_records as number | undefined) ?? + (obj.totalRecords as number | undefined) ?? + records.length, + errors: (obj.errors as string[] | undefined) ?? [], + }; + } + + async parseFile(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + this.fileData = new Uint8Array(arrayBuffer); + + // Dynamically import WASM module + const { quick_file_info, EvtxWasmParser } = await import( + "../wasm/evtx_wasm.js" + ); + + // Get file info + const fileInfo = await quick_file_info(this.fileData); + + // Persist file in IndexedDB for quick reloads + try { + const storage = await EvtxStorage.getInstance(); + await storage.saveFile(file, fileInfo.total_chunks as number); + } catch (err) { + // Non-blocking – log and continue + console.warn("Failed to persist EVTX file:", err); + } + + // Create parser instance – we cast it to the minimal interface we defined + // above. This avoids introducing `any` while still acknowledging the + // dynamic nature of the WASM import. + this.wasmParser = new EvtxWasmParser( + this.fileData + ) as unknown as WasmParserInstance; + + return { + fileName: file.name, + fileSize: file.size, + totalChunks: fileInfo.total_chunks as number, + nextRecordId: fileInfo.next_record_id as string, + isDirty: fileInfo.is_dirty as boolean, + isFull: fileInfo.is_full as boolean, + chunks: (fileInfo.chunks as unknown[]).map((c: unknown) => { + const chunkObj = c as Record; + return { + chunkNumber: chunkObj.chunk_number as number, + recordCount: chunkObj.record_count as string, + firstRecordId: chunkObj.first_record_id as string, + lastRecordId: chunkObj.last_record_id as string, + }; + }), + } as EvtxFileInfo; + } + + async parseAllRecords(): Promise { + if (!this.wasmParser) { + throw new Error("No file loaded"); + } + + const raw = await this.wasmParser.parse_all(); + return this.normaliseParseResult(raw); + } + + async parseChunk(chunkIndex: number): Promise { + if (!this.wasmParser) { + throw new Error("No file loaded"); + } + + const raw = await this.wasmParser.parse_chunk(chunkIndex); + return this.normaliseParseResult(raw); + } + + async parseWithLimit(limit: number): Promise { + if (!this.wasmParser) { + throw new Error("No file loaded"); + } + + const raw = await this.wasmParser.parse_with_limit(limit); + return this.normaliseParseResult(raw); + } + + async getRecordById(recordId: number): Promise { + if (!this.wasmParser) { + throw new Error("No file loaded"); + } + + try { + return (await this.wasmParser.get_record_by_id( + recordId + )) as EvtxRecord | null; + } catch (error) { + console.error(`Failed to get record ${recordId}:`, error); + return null; + } + } + + exportRecords(records: EvtxRecord[], format: "json" | "xml"): string { + if (format === "json") { + return JSON.stringify(records, null, 2); + } + + // For XML export, we would need to implement XML serialization + // For now, we'll use the JSON representation + // In a real implementation, we'd call the WASM parser's XML export + throw new Error("XML export not yet implemented in browser"); + } +} diff --git a/evtx-wasm/evtx-viewer/src/lib/storage.ts b/evtx-wasm/evtx-viewer/src/lib/storage.ts new file mode 100644 index 00000000..b0eea5a0 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/storage.ts @@ -0,0 +1,153 @@ +import { openDB, type DBSchema, type IDBPDatabase } from "idb"; + +// ----------------------------- +// DB Schema +// ----------------------------- +interface EvtxDB extends DBSchema { + files: { + // primary key is fileId (hash of name+size) + key: string; // fileId + value: { + fileId: string; // redundant but convenient for reads + fileName: string; + fileSize: number; + lastOpened: number; // epoch ms + pinned: boolean; + chunkCount: number; + totalRecords?: number; + // We keep the full file as a single Blob for now – slicing gives us chunk data + blob: Blob; + bucketCounts?: import("./types").BucketCounts; + }; + }; +} + +// ----------------------------- +// Helper +// ----------------------------- +class EvtxStorage { + private static instance: EvtxStorage; + private db!: IDBPDatabase; + + private constructor() {} + + static async getInstance(): Promise { + if (!EvtxStorage.instance) { + const inst = new EvtxStorage(); + await inst.init(); + EvtxStorage.instance = inst; + } + return EvtxStorage.instance; + } + + private async init() { + this.db = await openDB("evtx-viewer", 1, { + upgrade(db: IDBPDatabase) { + db.createObjectStore("files", { + keyPath: "fileId", + }); + }, + }); + } + + // Derive deterministic ID → `${name}_${size}_${mtime}` + async deriveFileId(file: File): Promise { + // We can’t get mtime directly from the browser File object (webkitRelativePath aside). + // So we hash name+size and current date if duplicates matter. + return `${file.name}_${file.size}`; + } + + async saveFile( + file: File, + chunkCount: number, + totalRecords?: number + ): Promise { + const fileId = await this.deriveFileId(file); + const tx = this.db.transaction("files", "readwrite"); + await tx.store.put({ + fileId, + fileName: file.name, + fileSize: file.size, + lastOpened: Date.now(), + pinned: false, + chunkCount, + totalRecords, + blob: file, + bucketCounts: undefined, + }); + await tx.done; + return fileId; + } + + async touchFile(fileId: string) { + const rec = await this.db.get("files", fileId); + if (rec) { + rec.lastOpened = Date.now(); + await this.db.put("files", rec); + } + } + + async listFiles(): Promise { + return await this.db.getAll("files"); + } + + async deleteFile(fileId: string) { + await this.db.delete("files", fileId); + } + + /** Retrieve both metadata and blob for a stored file. */ + async getFile( + fileId: string + ): Promise<{ meta: EvtxDB["files"]["value"]; blob: Blob }> { + const rec = await this.db.get("files", fileId); + if (!rec) throw new Error("file not found"); + return { meta: rec, blob: rec.blob }; + } + + async setPinned(fileId: string, pinned: boolean) { + const rec = await this.db.get("files", fileId); + if (rec) { + rec.pinned = pinned; + await this.db.put("files", rec); + } + } + + // ----------------------------- + // Bucket counts helpers + // ----------------------------- + + /** Store pre-computed bucket counts for the given file */ + async saveBucketCounts( + fileId: string, + buckets: import("./types").BucketCounts + ) { + const rec = await this.db.get("files", fileId); + if (rec) { + rec.bucketCounts = buckets; + await this.db.put("files", rec); + } + } + + /** Retrieve bucket counts if they were previously computed */ + async getBucketCounts( + fileId: string + ): Promise { + const rec = await this.db.get("files", fileId); + return rec?.bucketCounts; + } + + // Return the Blob slice that corresponds to the given chunk. + async getChunk( + fileId: string, + chunkIndex: number, + chunkSize = 0x10000 /* 64 KiB */ + ): Promise { + const rec = await this.db.get("files", fileId); + if (!rec) throw new Error("file not found"); + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, rec.fileSize); + return rec.blob.slice(start, end).arrayBuffer(); + } +} + +export default EvtxStorage; diff --git a/evtx-wasm/evtx-viewer/src/lib/types.ts b/evtx-wasm/evtx-viewer/src/lib/types.ts new file mode 100644 index 00000000..1cba65d9 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/types.ts @@ -0,0 +1,141 @@ +// Core types for EVTX parsing +export interface EvtxFileInfo { + fileName: string; + fileSize: number; + totalChunks: number; + /** May exceed JavaScript's safe integer range so we treat it as a string */ + nextRecordId: string; + isDirty: boolean; + isFull: boolean; + chunks: ChunkInfo[]; +} + +export interface ChunkInfo { + chunkNumber: number; + recordCount: string; + /** Potentially very large – keep as string */ + firstRecordId: string; + lastRecordId: string; +} + +export interface EvtxRecord { + Event: { + System: EvtxSystemData; + EventData?: EvtxEventData | null; + UserData?: unknown; + RenderingInfo?: unknown; + }; +} + +export interface EvtxSystemData { + Provider?: { + Name?: string; + Guid?: string; + }; + Provider_attributes?: { + Name?: string; + Guid?: string; + }; + EventID?: number | string; + Version?: number; + Level?: number; + Task?: number; + Opcode?: number; + Keywords?: string; + TimeCreated?: { + SystemTime?: string; + }; + TimeCreated_attributes?: { + SystemTime?: string; + }; + EventRecordID?: number; + Correlation?: unknown; + Execution?: { + ProcessID?: number; + ThreadID?: number; + }; + Execution_attributes?: { + ProcessID?: number; + ThreadID?: number; + }; + Channel?: string; + Computer?: string; + Security?: { + UserID?: string; + }; + Security_attributes?: { + UserID?: string; + }; +} + +export interface EvtxEventData { + Data?: DataElement | DataElement[]; + "#text"?: string; + [key: string]: unknown; +} + +export interface DataElement { + "#text"?: string; + "#attributes"?: { + Name?: string; + }; +} + +export interface ParseResult { + records: EvtxRecord[]; + totalRecords: number; + errors: string[]; +} + +export interface TableColumn { + id: string; + header: string; + /** DuckDB select expression. Must alias to the same `id` */ + sqlExpr: string; + /** Optional value formatter – receives the raw value returned from SQL */ + accessor?: (row: Record) => unknown; + width?: number; + sortable?: boolean; +} + +/** Alias kept for clarity – identical to TableColumn */ +export type ColumnSpec = TableColumn; + +export type ExportFormat = "json" | "xml"; + +export interface FilterOptions { + searchTerm?: string; + level?: number[]; + eventId?: number[]; + timeRange?: { + start: Date; + end: Date; + }; + provider?: string[]; + channel?: string[]; + /** + * Filters applied to specific EventData fields. The map key is the field + * name (e.g. "SubjectUserSid") and the value is a list of accepted values + * for that field. All active EventData field filters are AND-ed together + * in the query, matching any record where the field’s value equals one of + * the provided strings. + */ + eventData?: Record; + + /** + * Exclusion filters for EventData. Records whose field value matches any + * of the listed values will be filtered out. + */ + eventDataExclude?: Record; + + /** Generic equality filters keyed by column id (id refers to TableColumn.id). */ + columnEquals?: Record; +} + +// Pre-computed facet buckets across the entire log file +export interface BucketCounts { + level: Record; + provider: Record; + channel: Record; + event_id: Record; +} diff --git a/evtx-wasm/evtx-viewer/src/lib/useChunkVirtualizer.ts b/evtx-wasm/evtx-viewer/src/lib/useChunkVirtualizer.ts new file mode 100644 index 00000000..dcb0cc0f --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/lib/useChunkVirtualizer.ts @@ -0,0 +1,268 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useVirtualizer, Virtualizer } from "@tanstack/react-virtual"; +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { DuckDbDataSource } from "./duckDbDataSource"; +import { logger } from "./logger"; + +export interface ChunkVirtualizer { + /** Attach this to the *scrollable* element that should drive scrolling */ + containerRef: React.MutableRefObject; + /** Underlying tanstack virtualizer (per-chunk). Consumers usually only + * need `getVirtualItems()` and `getTotalSize()`. */ + virtualizer: Virtualizer; + /** Map of *loaded* chunks ⇒ their record arrays */ + chunkRows: Map; + /** prefix[i] === global row offset at *start* of chunk i */ + prefix: number[]; + /** Upper-bound on total number of rows (becomes exact once all chunks + * were loaded). Safe to use for virtualizer row counts etc. */ + totalRows: number; + /** Explicitly trigger loading of a chunk (hook does this automatically + * for visible chunks). */ + ensureChunk: (idx: number) => void; +} + +interface UseChunkVirtualizerOpts { + dataSource: DuckDbDataSource; + /** Fixed pixel height of *one* rendered row. */ + rowHeight: number; + /** Estimated #records per chunk *before* we actually load it. */ + estimateRowsPerChunk?: number; + /** Overscan #chunks for the tanstack virtualizer. */ + overscanChunks?: number; + /** Optional predicate – only rows that return true will be kept. */ + filterFn?: (rec: any) => boolean; +} + +export function useChunkVirtualizer({ + dataSource, + rowHeight, + estimateRowsPerChunk = 4000, + overscanChunks = 2, + filterFn, +}: UseChunkVirtualizerOpts): ChunkVirtualizer { + // --- bookkeeping ------------------------------------------------------ + const [chunkCount, setChunkCount] = useState(0); + const [chunkRows, setChunkRows] = useState>( + () => new Map() + ); + const loadingChunks = useRef>(new Set()); + + // Exact per-chunk record counts discovered during init (may be empty until + // dataSource.init() resolves). + const [chunkRecordCounts, setChunkRecordCounts] = useState([]); + + // Initialise chunk count *once* + useEffect(() => { + let mounted = true; + (async () => { + await dataSource.init(); + if (mounted) { + const cnt = dataSource.getChunkCount(); + const counts = dataSource.getChunkRecordCounts(); + logger.info("ChunkDataSource initialised", { + chunks: cnt, + records: counts.reduce((a, b) => a + b, 0), + }); + setChunkCount(cnt); + setChunkRecordCounts(counts); + } + })(); + return () => { + mounted = false; + }; + }, [dataSource]); + + // --- virtualiser ------------------------------------------------------ + const containerRef = useRef(null); + const virtualizer = useVirtualizer({ + count: chunkCount, + getScrollElement: () => containerRef.current, + estimateSize: (idx) => { + const header = chunkRecordCounts[idx]; + return rowHeight * (header ?? estimateRowsPerChunk); + }, + overscan: overscanChunks, + }); + + // Reset all cached measurements & state when the *dataSource* instance changes + useEffect(() => { + // Clear any in-flight loads for the previous data source + loadingChunks.current.clear(); + + // Drop any rows we already downloaded so the new file starts fresh + setChunkRows(new Map()); + setChunkCount(0); + + // Tell the virtualizer to forget all previously sized items + virtualizer.measure(); + + // Forget any cached record counts until the new file's init() runs + setChunkRecordCounts([]); + + // Reset scroll offset to the top so that the first chunk becomes visible + if (containerRef.current) { + containerRef.current.scrollTop = 0; + } + virtualizer.scrollToOffset(0); + + logger.info("dataSourceChanged – cache cleared & virtualizer reset"); + }, [dataSource, virtualizer, estimateRowsPerChunk]); + + // When the filter predicate changes we need to re-evaluate *all* already + // loaded chunks so that their heights & row counts reflect the new filter. + useEffect(() => { + if (!filterFn) { + // If no filter, nothing to update – but we still clear so that each + // chunk will be re-sized based on its full record count. + setChunkRows(new Map()); + virtualizer.measure(); + return; + } + + // Re-filter synchronously if we already have the original rows cached in + // the data source (which we should). Otherwise chunks will update lazily + // the next time they load. + (async () => { + const newMap = new Map(); + for (const idx of chunkRows.keys()) { + const original = await dataSource.getChunk(idx); + newMap.set(idx, original.filter(filterFn)); + + // Update the virtualizer item size immediately. + virtualizer.resizeItem(idx, newMap.get(idx)!.length * rowHeight); + } + setChunkRows(newMap); + })(); + }, [filterFn, dataSource, rowHeight, virtualizer]); + + // --- chunk loader helper --------------------------------------------- + const ensureChunk = useCallback( + (idx: number) => { + if (chunkRows.has(idx) || loadingChunks.current.has(idx)) return; + logger.debug("ensureChunk", { idx }); + loadingChunks.current.add(idx); + void dataSource.getChunk(idx).then((records) => { + const filtered = filterFn ? records.filter(filterFn) : records; + + setChunkRows((prev) => new Map(prev).set(idx, filtered)); + + logger.debug("chunkLoaded", { + idx, + original: records.length, + kept: filtered.length, + }); + logger.info("chunkLoaded", { + idx, + original: records.length, + kept: filtered.length, + }); + loadingChunks.current.delete(idx); + + const estimatedPx = + (chunkRecordCounts[idx] ?? estimateRowsPerChunk) * rowHeight; + const exact = filtered.length * rowHeight; + + logger.debug("chunkResize", { + idx, + estimatedPx, + exactPx: exact, + diffPx: exact - estimatedPx, + }); + + virtualizer.resizeItem(idx, exact); + + logger.debug("virtualizerTotalSize", { + idx, + totalSizePx: virtualizer.getTotalSize(), + }); + + /* + * We previously called `virtualizer.measure()` when the **last** chunk + * finished loading in an attempt to force a total-size recalculation. + * + * Unfortunately that had the opposite effect: `measure()` blows away + * all `resizeItem()` overrides we just applied and falls back to the + * (often wildly over-estimated) `estimateSize` callback, which in turn + * made the virtual content much taller than the real data -> a large + * blank area at the bottom of the table. + * + * Because every chunk already receives an exact `resizeItem()` as soon + * as it loads, the total size is *already* correct at this point, so + * running `measure()` is unnecessary – and actively harmful. + */ + }); + }, + [ + chunkRows, + dataSource, + rowHeight, + virtualizer, + chunkCount, + chunkRecordCounts, + estimateRowsPerChunk, + filterFn, + ] + ); + + // --- prefix / total rows --------------------------------------------- + const prefix = useMemo(() => { + const arr: number[] = new Array(chunkCount); + let offset = 0; + for (let i = 0; i < chunkCount; i++) { + arr[i] = offset; + const rowsInChunk = + chunkRows.get(i)?.length ?? + chunkRecordCounts[i] ?? + estimateRowsPerChunk; + offset += rowsInChunk; + } + logger.debug("prefixRecalc", { chunkCount, lastOffset: offset }); + return arr; + }, [chunkCount, chunkRows, chunkRecordCounts]); + + const totalRows = useMemo(() => { + if (chunkCount === 0) return 0; + const last = chunkCount - 1; + const total = + prefix[last] + + (chunkRows.get(last)?.length ?? + Math.min( + estimateRowsPerChunk, + chunkRecordCounts[last] ?? estimateRowsPerChunk + )); + logger.debug("totalRows", { total }); + return total; + }, [chunkCount, prefix, chunkRows, chunkRecordCounts]); + + // Log whenever totalRows changes so we can correlate with blank space + useEffect(() => { + logger.debug("totalRowsChanged", { + totalRows, + virtualizerTotalPx: virtualizer.getTotalSize(), + }); + }, [totalRows, virtualizer]); + + // Whenever virtual items change, proactively load the *visible* chunks + useEffect(() => { + const v = virtualizer.getVirtualItems(); + if (v.length) { + logger.debug("virtualItems", { + first: v[0].index, + last: v[v.length - 1].index, + count: v.length, + }); + } + v.forEach((vi) => ensureChunk(vi.index)); + }, [virtualizer.getVirtualItems(), ensureChunk]); + + // --------------------------------------------------------------------- + return { + containerRef, + virtualizer, + chunkRows, + prefix, + totalRows, + ensureChunk, + }; +} diff --git a/evtx-wasm/evtx-viewer/src/main.tsx b/evtx-wasm/evtx-viewer/src/main.tsx new file mode 100644 index 00000000..c5513e1f --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/main.tsx @@ -0,0 +1,17 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import { ThemeModeProvider } from "./styles/ThemeModeProvider"; +import { GlobalProvider } from "./state/store"; +import { StoreBootstrap } from "./state/StoreBootstrap"; + +createRoot(document.getElementById("root")!).render( + + + + + + + + +); diff --git a/evtx-wasm/evtx-viewer/src/state/StoreBootstrap.tsx b/evtx-wasm/evtx-viewer/src/state/StoreBootstrap.tsx new file mode 100644 index 00000000..c364aba7 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/StoreBootstrap.tsx @@ -0,0 +1,24 @@ +import { useEffect } from "react"; +import { useGlobalDispatch } from "./store"; +import { setFilters } from "./filters/filtersSlice"; +import { setColumns } from "./columns/columnsSlice"; +import { getDefaultColumns } from "../lib/columns"; +import type { FilterOptions } from "../lib/types"; + +/** + * One-time bootstrap component. Mount it once under GlobalProvider + * to seed default columns and optional initial filters. + */ +export const StoreBootstrap: React.FC<{ initialFilters?: FilterOptions }> = ({ + initialFilters = {}, +}) => { + const dispatch = useGlobalDispatch(); + + useEffect(() => { + dispatch(setFilters(initialFilters)); + dispatch(setColumns(getDefaultColumns())); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return null; +}; diff --git a/evtx-wasm/evtx-viewer/src/state/columns/columnsSlice.ts b/evtx-wasm/evtx-viewer/src/state/columns/columnsSlice.ts new file mode 100644 index 00000000..c38e31fc --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/columns/columnsSlice.ts @@ -0,0 +1,44 @@ +import type { TableColumn } from "../../lib/types"; + +// ---------------- Initial State ---------------- +export const columnsInitialState: TableColumn[] = []; + +// ---------------- Action Types ---------------- +export type ColumnsAction = + | { type: "columns/SET"; payload: TableColumn[] } + | { type: "columns/ADD"; payload: TableColumn } + | { type: "columns/REMOVE"; payload: string }; + +// ---------------- Reducer ---------------- +export function columnsReducer( + state: TableColumn[] = columnsInitialState, + action: ColumnsAction +): TableColumn[] { + switch (action.type) { + case "columns/SET": + return action.payload; + case "columns/ADD": + if (state.some((c) => c.id === action.payload.id)) return state; + return [...state, action.payload]; + case "columns/REMOVE": + return state.filter((c) => c.id !== action.payload); + default: + return state; + } +} + +// ---------------- Action Creators ---------------- +export const setColumns = (payload: TableColumn[]): ColumnsAction => ({ + type: "columns/SET", + payload, +}); + +export const addColumn = (payload: TableColumn): ColumnsAction => ({ + type: "columns/ADD", + payload, +}); + +export const removeColumn = (id: string): ColumnsAction => ({ + type: "columns/REMOVE", + payload: id, +}); diff --git a/evtx-wasm/evtx-viewer/src/state/evtx/evtxSlice.ts b/evtx-wasm/evtx-viewer/src/state/evtx/evtxSlice.ts new file mode 100644 index 00000000..9b64d115 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/evtx/evtxSlice.ts @@ -0,0 +1,47 @@ +import type { EvtxFileInfo } from "../../lib/types"; + +// ---------------- Types & State ---------------- +export interface EvtxMetaState { + isLoading: boolean; + loadingMessage: string; + matchedCount: number; + totalRecords: number; + fileInfo: EvtxFileInfo | null; + currentFileId: string | null; +} + +export const evtxInitialState: EvtxMetaState = { + isLoading: false, + loadingMessage: "", + matchedCount: 0, + totalRecords: 0, + fileInfo: null, + currentFileId: null, +}; + +// ---------------- Action Types ---------------- +export type EvtxAction = { + type: "evtx/UPDATE"; + payload: Partial; +}; + +// ---------------- Reducer ---------------- +export function evtxReducer( + state: EvtxMetaState = evtxInitialState, + action: EvtxAction +): EvtxMetaState { + switch (action.type) { + case "evtx/UPDATE": + return { ...state, ...action.payload }; + default: + return state; + } +} + +// ---------------- Action Creators ---------------- +export const updateEvtxMeta = ( + payload: Partial +): EvtxAction => ({ + type: "evtx/UPDATE", + payload, +}); diff --git a/evtx-wasm/evtx-viewer/src/state/filters/filtersSlice.ts b/evtx-wasm/evtx-viewer/src/state/filters/filtersSlice.ts new file mode 100644 index 00000000..ac313fe8 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/filters/filtersSlice.ts @@ -0,0 +1,44 @@ +import type { FilterOptions } from "../../lib/types"; + +// ---------------- Initial State ---------------- +export const filtersInitialState: FilterOptions = {}; + +// ---------------- Action Types ---------------- +export type FiltersAction = + | { type: "filters/SET"; payload: FilterOptions } + | { type: "filters/UPDATE"; payload: Partial } + | { type: "filters/CLEAR" }; + +// ---------------- Reducer ---------------- +export function filtersReducer( + state: FilterOptions = filtersInitialState, + action: FiltersAction +): FilterOptions { + switch (action.type) { + case "filters/SET": + return action.payload; + case "filters/UPDATE": + return { ...state, ...action.payload }; + case "filters/CLEAR": + return {}; + default: + return state; + } +} + +// ---------------- Action Creators ---------------- +export const setFilters = (payload: FilterOptions): FiltersAction => ({ + type: "filters/SET", + payload, +}); + +export const updateFilters = ( + payload: Partial +): FiltersAction => ({ + type: "filters/UPDATE", + payload, +}); + +export const clearFilters = (): FiltersAction => ({ + type: "filters/CLEAR", +}); diff --git a/evtx-wasm/evtx-viewer/src/state/ingest/ingestSlice.ts b/evtx-wasm/evtx-viewer/src/state/ingest/ingestSlice.ts new file mode 100644 index 00000000..df7853c9 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/ingest/ingestSlice.ts @@ -0,0 +1,47 @@ +// Simple ingest slice managing progress & totals + +export interface IngestState { + progress: number; // 0..1 + totalRecords: number; + currentFileId: string | null; +} + +export const ingestInitialState: IngestState = { + progress: 1, + totalRecords: 0, + currentFileId: null, +}; + +export type IngestAction = + | { type: "ingest/SET_PROGRESS"; payload: number } + | { type: "ingest/SET_TOTAL"; payload: number } + | { type: "ingest/SET_FILE_ID"; payload: string | null }; + +export function ingestReducer( + state: IngestState = ingestInitialState, + action: IngestAction +): IngestState { + switch (action.type) { + case "ingest/SET_PROGRESS": + return { ...state, progress: action.payload }; + case "ingest/SET_TOTAL": + return { ...state, totalRecords: action.payload }; + case "ingest/SET_FILE_ID": + return { ...state, currentFileId: action.payload }; + default: + return state; + } +} + +export const setIngestProgress = (pct: number): IngestAction => ({ + type: "ingest/SET_PROGRESS", + payload: pct, +}); +export const setIngestTotal = (total: number): IngestAction => ({ + type: "ingest/SET_TOTAL", + payload: total, +}); +export const setIngestFileId = (id: string | null): IngestAction => ({ + type: "ingest/SET_FILE_ID", + payload: id, +}); diff --git a/evtx-wasm/evtx-viewer/src/state/rootReducer.ts b/evtx-wasm/evtx-viewer/src/state/rootReducer.ts new file mode 100644 index 00000000..93337408 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/rootReducer.ts @@ -0,0 +1,47 @@ +import { filtersReducer, filtersInitialState } from "./filters/filtersSlice"; +import { columnsReducer, columnsInitialState } from "./columns/columnsSlice"; +import { ingestReducer, ingestInitialState } from "./ingest/ingestSlice"; +import type { IngestState } from "./ingest/ingestSlice"; +import { evtxReducer, evtxInitialState } from "./evtx/evtxSlice"; +import type { EvtxMetaState, EvtxAction } from "./evtx/evtxSlice"; + +import type { FiltersAction } from "./filters/filtersSlice"; +import type { ColumnsAction } from "./columns/columnsSlice"; +import type { IngestAction } from "./ingest/ingestSlice"; + +import type { FilterOptions, TableColumn } from "../lib/types"; + +// ----------------- Global State & Actions ----------------- + +export interface GlobalState { + filters: FilterOptions; + columns: TableColumn[]; + ingest: IngestState; + evtx: EvtxMetaState; +} + +export type GlobalAction = + | FiltersAction + | ColumnsAction + | IngestAction + | EvtxAction; + +export const globalInitialState: GlobalState = { + filters: filtersInitialState, + columns: columnsInitialState, + ingest: ingestInitialState, + evtx: evtxInitialState, +}; + +// Root reducer delegates to slice reducers. +export function rootReducer( + state: GlobalState = globalInitialState, + action: GlobalAction +): GlobalState { + return { + filters: filtersReducer(state.filters, action as FiltersAction), + columns: columnsReducer(state.columns, action as ColumnsAction), + ingest: ingestReducer(state.ingest, action as IngestAction), + evtx: evtxReducer(state.evtx, action as EvtxAction), + }; +} diff --git a/evtx-wasm/evtx-viewer/src/state/store.tsx b/evtx-wasm/evtx-viewer/src/state/store.tsx new file mode 100644 index 00000000..da3575a5 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/state/store.tsx @@ -0,0 +1,51 @@ +import React, { createContext, useContext, useReducer } from "react"; +import type { Dispatch, ReactNode } from "react"; +import { rootReducer, globalInitialState } from "./rootReducer"; +import type { GlobalState, GlobalAction } from "./rootReducer"; + +// Separate contexts for state and dispatch to minimise re-renders +const StateCtx = createContext(undefined); +const DispatchCtx = createContext | undefined>( + undefined +); + +interface ProviderProps { + children: ReactNode; +} + +export const GlobalProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(rootReducer, globalInitialState); + return ( + + {children} + + ); +}; + +// ---------------- Selector + Dispatch hooks ---------------- + +// eslint-disable-next-line react-refresh/only-export-components +export function useGlobalState(selector: (s: GlobalState) => T): T { + const state = useContext(StateCtx); + if (!state) + throw new Error("useGlobalState must be used within GlobalProvider"); + return selector(state); +} + +// eslint-disable-next-line react-refresh/only-export-components +export function useGlobalDispatch(): Dispatch { + const dispatch = useContext(DispatchCtx); + if (!dispatch) + throw new Error("useGlobalDispatch must be used within GlobalProvider"); + return dispatch; +} + +// Convenience slice hooks (maintain API parity) +// eslint-disable-next-line react-refresh/only-export-components +export const useFiltersState = () => useGlobalState((s) => s.filters); +// eslint-disable-next-line react-refresh/only-export-components +export const useColumnsState = () => useGlobalState((s) => s.columns); +// eslint-disable-next-line react-refresh/only-export-components +export const useIngestState = () => useGlobalState((s) => s.ingest); +// eslint-disable-next-line react-refresh/only-export-components +export const useEvtxMetaState = () => useGlobalState((s) => s.evtx); diff --git a/evtx-wasm/evtx-viewer/src/styles/GlobalStyles.ts b/evtx-wasm/evtx-viewer/src/styles/GlobalStyles.ts new file mode 100644 index 00000000..258a27eb --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/styles/GlobalStyles.ts @@ -0,0 +1,118 @@ +import { createGlobalStyle } from "styled-components"; + +export const GlobalStyles = createGlobalStyle` + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html, body { + height: 100%; + overflow: hidden; + } + + body { + font-family: ${({ theme }) => theme.fonts.body}; + font-size: ${({ theme }) => theme.fontSize.body}; + color: ${({ theme }) => theme.colors.text.primary}; + background-color: ${({ theme }) => theme.colors.background.primary}; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + #root { + height: 100%; + display: flex; + flex-direction: column; + } + + /* Windows-style scrollbar */ + ::-webkit-scrollbar { + width: 12px; + height: 12px; + } + + ::-webkit-scrollbar-track { + background: ${({ theme }) => theme.colors.background.primary}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + } + + ::-webkit-scrollbar-thumb { + background: ${({ theme }) => theme.colors.border.medium}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + border: 1px solid ${({ theme }) => theme.colors.border.light}; + } + + ::-webkit-scrollbar-thumb:hover { + background: ${({ theme }) => theme.colors.border.dark}; + } + + /* Selection */ + ::selection { + background-color: ${({ theme }) => theme.colors.selection.background}; + color: ${({ theme }) => theme.colors.text.primary}; + } + + /* Focus styles */ + :focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.accent.primary}; + outline-offset: 2px; + } + + /* Disable focus outline for mouse users */ + :focus:not(:focus-visible) { + outline: none; + } + + /* Tables */ + table { + border-collapse: collapse; + width: 100%; + } + + /* Links */ + a { + color: ${({ theme }) => theme.colors.text.link}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + /* Code */ + code, pre { + font-family: ${({ theme }) => theme.fonts.mono}; + font-size: ${({ theme }) => theme.fontSize.caption}; + } + + /* Tooltips */ + [data-tooltip] { + position: relative; + + &::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background-color: ${({ theme }) => theme.colors.text.primary}; + color: ${({ theme }) => theme.colors.text.white}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => + theme.spacing.sm}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.caption}; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity ${({ theme }) => theme.transitions.fast}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + } + + &:hover::after { + opacity: 1; + visibility: visible; + } + } +`; diff --git a/evtx-wasm/evtx-viewer/src/styles/ThemeModeProvider.tsx b/evtx-wasm/evtx-viewer/src/styles/ThemeModeProvider.tsx new file mode 100644 index 00000000..76ac70b8 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/styles/ThemeModeProvider.tsx @@ -0,0 +1,57 @@ +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, +} from "react"; +import { ThemeProvider } from "styled-components"; +import { lightTheme, darkTheme, type ThemeMode } from "./theme"; +/* eslint-disable react-refresh/only-export-components */ + +interface ThemeModeContextValue { + mode: ThemeMode; + toggle: () => void; +} + +const ThemeModeContext = createContext({ + mode: "light", + /* eslint-disable-next-line @typescript-eslint/no-empty-function */ + toggle: () => {}, +}); + +export const useThemeMode = (): ThemeModeContextValue => + useContext(ThemeModeContext); + +export const ThemeModeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [mode, setMode] = useState(() => { + if (typeof window !== "undefined") { + const stored = window.localStorage.getItem("theme-mode"); + if (stored === "light" || stored === "dark") return stored; + } + return "light"; + }); + + const toggle = useCallback(() => { + setMode((prev) => { + const next: ThemeMode = prev === "light" ? "dark" : "light"; + if (typeof window !== "undefined") { + window.localStorage.setItem("theme-mode", next); + } + return next; + }); + }, []); + + const theme = useMemo( + () => (mode === "dark" ? darkTheme : lightTheme), + [mode] + ); + + return ( + + {children} + + ); +}; diff --git a/evtx-wasm/evtx-viewer/src/styles/styled.d.ts b/evtx-wasm/evtx-viewer/src/styles/styled.d.ts new file mode 100644 index 00000000..a7f19b75 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/styles/styled.d.ts @@ -0,0 +1,15 @@ +import "styled-components"; +import { theme } from "./theme"; + +type AppTheme = typeof theme; + +declare module "styled-components" { + export interface DefaultTheme extends AppTheme { + /** + * NOTE: This dummy property exists solely to avoid TypeScript's + * "empty interface" restriction when `no-empty-interface` is enabled. + * It has no practical effect at runtime. + */ + readonly __brand?: "DefaultTheme"; + } +} diff --git a/evtx-wasm/evtx-viewer/src/styles/theme.ts b/evtx-wasm/evtx-viewer/src/styles/theme.ts new file mode 100644 index 00000000..d06a05c0 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/styles/theme.ts @@ -0,0 +1,126 @@ +// Windows 11 inspired theme +export const lightTheme = { + colors: { + // Windows 11 color palette + background: { + primary: "#F3F3F3", + secondary: "#FFFFFF", + tertiary: "#F9F9F9", + hover: "#F5F5F5", + active: "#E0E0E0", + dark: "#202020", + }, + text: { + primary: "#000000", + secondary: "#5C5C5C", + tertiary: "#8B8B8B", + link: "#0066CC", + white: "#FFFFFF", + }, + accent: { + primary: "#0078D4", + hover: "#106EBE", + active: "#005A9E", + light: "#40E0D0", + }, + border: { + light: "#E0E0E0", + medium: "#C8C8C8", + dark: "#A0A0A0", + }, + status: { + error: "#C42B1C", + warning: "#F7630C", + success: "#107C10", + info: "#0078D4", + }, + selection: { + background: "#E5F1FB", + border: "#0078D4", + }, + }, + fonts: { + body: '"Segoe UI", -apple-system, BlinkMacSystemFont, "Roboto", "Helvetica Neue", sans-serif', + mono: '"Cascadia Code", "Consolas", "Courier New", monospace', + }, + fontSize: { + caption: "12px", + body: "14px", + subtitle: "16px", + title: "20px", + header: "28px", + }, + spacing: { + xs: "4px", + sm: "8px", + md: "12px", + lg: "16px", + xl: "20px", + xxl: "32px", + }, + borderRadius: { + sm: "4px", + md: "6px", + lg: "8px", + }, + shadows: { + sm: "0 1px 2px rgba(0, 0, 0, 0.08)", + md: "0 2px 4px rgba(0, 0, 0, 0.08)", + lg: "0 4px 8px rgba(0, 0, 0, 0.12)", + elevation: "0 8px 16px rgba(0, 0, 0, 0.14)", + }, + transitions: { + fast: "120ms ease-out", + normal: "200ms ease-out", + slow: "300ms ease-out", + }, +}; + +// --------------------------------------------- +// Dark-mode palette – deliberately keeps the +// exact same token structure so existing styled +// components continue to work. Only the color +// values change. Feel free to tweak further. +// --------------------------------------------- + +export const darkTheme: typeof lightTheme = { + ...lightTheme, + colors: { + ...lightTheme.colors, + background: { + primary: "#1F1F1F", + secondary: "#252526", + tertiary: "#2D2D2D", + hover: "#37373D", + active: "#3F3F46", + dark: "#000000", + }, + text: { + primary: "#F3F3F3", + secondary: "#C1C1C1", + tertiary: "#9B9B9B", + link: "#3794FF", + white: "#FFFFFF", + }, + accent: { + primary: "#0A84FF", + hover: "#3391FF", + active: "#006EDC", + light: "#40E0D0", + }, + border: { + light: "#3C3C3C", + medium: "#505050", + dark: "#707070", + }, + selection: { + background: "#264F78", + border: "#3794FF", + }, + }, +}; + +// Backwards-compat: keep named export `theme` pointing at light theme. +export const theme = lightTheme; + +export type ThemeMode = "light" | "dark"; diff --git a/evtx-wasm/evtx-viewer/src/vite-env.d.ts b/evtx-wasm/evtx-viewer/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/evtx-wasm/evtx-viewer/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/evtx-wasm/evtx-viewer/tsconfig.app.json b/evtx-wasm/evtx-viewer/tsconfig.app.json new file mode 100644 index 00000000..10707e69 --- /dev/null +++ b/evtx-wasm/evtx-viewer/tsconfig.app.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "skipLibCheck": true, + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "allowJs": true, + /* Linting */ + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ] +} diff --git a/evtx-wasm/evtx-viewer/tsconfig.json b/evtx-wasm/evtx-viewer/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/evtx-wasm/evtx-viewer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/evtx-wasm/evtx-viewer/tsconfig.node.json b/evtx-wasm/evtx-viewer/tsconfig.node.json new file mode 100644 index 00000000..f85a3990 --- /dev/null +++ b/evtx-wasm/evtx-viewer/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/evtx-wasm/evtx-viewer/vite.config.ts b/evtx-wasm/evtx-viewer/vite.config.ts new file mode 100644 index 00000000..02bbd5c1 --- /dev/null +++ b/evtx-wasm/evtx-viewer/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vite.dev/config/ +export default defineConfig(({ command }) => ({ + base: command === "serve" ? "/" : "/evtx/", + plugins: [react()], + server: { + port: 3000, + fs: { + allow: [".."], + }, + }, + build: { + // Ensure WASM files are properly handled + assetsInlineLimit: 0, + }, + optimizeDeps: { + exclude: ["./src/wasm/evtx_wasm.js"], + }, + assetsInclude: ["**/*.wasm"], +})); diff --git a/evtx-wasm/src/lib.rs b/evtx-wasm/src/lib.rs new file mode 100644 index 00000000..eb60490f --- /dev/null +++ b/evtx-wasm/src/lib.rs @@ -0,0 +1,661 @@ +use arrow2::array::MutableArray; +use arrow2::{ + array::{MutablePrimitiveArray, MutableUtf8Array}, + chunk::Chunk, + datatypes::{DataType, Field, Schema}, + io::ipc::write::StreamWriter, +}; +use evtx::{EvtxParser, ParserSettings}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::io::Cursor; +use wasm_bindgen::prelude::*; + +// Set panic hook for better error messages in the browser +#[wasm_bindgen(start)] +pub fn main() { + console_error_panic_hook::set_once(); +} + +#[derive(Serialize, Deserialize)] +pub struct ParseResult { + pub records: Vec, + pub total_records: usize, + pub chunk_count: usize, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ChunkInfo { + pub chunk_number: u64, + /// Number of records in the chunk. Serialised as string to avoid + /// potential 64-bit overflow issues on the JS side. + pub record_count: String, + /// These IDs may exceed JavaScript's safe integer range, so we serialise + /// them as strings. + pub first_record_id: String, + pub last_record_id: String, +} + +#[derive(Serialize, Deserialize)] +pub struct FileInfo { + pub chunks: Vec, + pub total_chunks: usize, + pub first_chunk: u64, + pub last_chunk: u64, + /// Use a string here to avoid `serde_wasm_bindgen` errors when the value + /// exceeds JavaScript's safe integer range (2^53-1). + pub next_record_id: String, + pub is_dirty: bool, + pub is_full: bool, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct BucketCounts { + pub level: HashMap, + pub provider: HashMap, + pub channel: HashMap, + pub event_id: HashMap, +} + +/// Compute distinct values + counts for common facets across **all** records. +/// Returned object shape (JSON): +/// { +/// level: { "0": 123, "4": 456, ... }, +/// provider: { "Microsoft-Windows-Security-Auditing": 789, ... }, +/// channel: { "Security": 789, ... }, +/// event_id: { "4688": 321, ... } +/// } +#[wasm_bindgen] +pub fn compute_buckets(data: &[u8]) -> Result { + let cursor = Cursor::new(data); + let settings = ParserSettings::default() + .separate_json_attributes(true) + .indent(false); + + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))? + .with_configuration(settings); + + let mut buckets: BucketCounts = BucketCounts::default(); + let mut record_counter = 0u64; + + for record in parser.records_json_value() { + record_counter += 1; + let rec = match record { + Ok(r) => r.data, + Err(_) => continue, + }; + + // Navigate to Event.System if present + let sys = match rec.get("Event").and_then(|v| v.get("System")) { + Some(s) => s, + None => continue, + }; + + // Level + if let Some(level_val) = sys.get("Level") { + let key = level_val.to_string(); + *buckets.level.entry(key).or_insert(0) += 1; + } + + // Provider.Name – might be nested under Provider or Provider_attributes + if let Some(provider_name) = sys + .get("Provider") + .and_then(|p| p.get("Name")) + .or_else(|| sys.get("Provider_attributes").and_then(|p| p.get("Name"))) + { + let key_owned = provider_name + .as_str() + .map(|s| s.to_owned()) + .unwrap_or_else(|| provider_name.to_string()); + *buckets.provider.entry(key_owned).or_insert(0) += 1; + } + + // Channel + if let Some(ch) = sys.get("Channel") { + let key_owned = ch + .as_str() + .map(|s| s.to_owned()) + .unwrap_or_else(|| ch.to_string()); + *buckets.channel.entry(key_owned).or_insert(0) += 1; + } + + // EventID (may be object when attributes enabled) + if let Some(eid) = sys.get("EventID") { + let id_str = if eid.is_object() { + eid.get("#text") + .and_then(|v| v.as_str()) + .unwrap_or(&eid.to_string()) + .to_owned() + } else if eid.is_string() { + eid.as_str().unwrap().to_owned() + } else { + eid.to_string() + }; + *buckets.event_id.entry(id_str).or_insert(0) += 1; + } + } + + // DEBUG: emit some stats to the browser console so we can confirm logic works. + #[cfg(target_arch = "wasm32")] + { + use web_sys::console; + console::log_1(&JsValue::from_str(&format!( + "compute_buckets finished – processed {} records, level keys={} provider keys={} channel keys={} event_id keys={}", + record_counter, + buckets.level.len(), + buckets.provider.len(), + buckets.channel.len(), + buckets.event_id.len() + ))); + } + + let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + + buckets + .serialize(&serializer) + .map_err(|e| JsError::new(&format!("Failed to serialise buckets: {}", e))) +} + +#[wasm_bindgen] +pub struct EvtxWasmParser { + data: Vec, +} + +#[wasm_bindgen] +impl EvtxWasmParser { + #[wasm_bindgen(constructor)] + pub fn new(data: &[u8]) -> Result { + Ok(EvtxWasmParser { + data: data.to_vec(), + }) + } + + /// Get file header information + #[wasm_bindgen] + pub fn get_file_info(&self) -> Result { + // Parse header from raw data + let mut header_cursor = Cursor::new(&self.data[..4096.min(self.data.len())]); + let header = evtx::EvtxFileHeader::from_stream(&mut header_cursor) + .map_err(|e| JsError::new(&format!("Failed to parse header: {}", e)))?; + + let cursor = Cursor::new(&self.data); + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + + let mut chunks = Vec::new(); + + // Collect chunk information + for (chunk_number, chunk) in parser.chunks().enumerate() { + match chunk { + Ok(mut chunk_data) => { + let chunk_settings = ParserSettings::default(); + match chunk_data.parse(std::sync::Arc::new(chunk_settings)) { + Ok(chunk) => { + // In rare corrupted files `last_event_record_number` can be + // lower than `first_event_record_number`, which would wrap the + // subtraction and produce a huge `u64`. Guard against that and + // clamp to 0. + let safe_record_count = if chunk.header.last_event_record_number + >= chunk.header.first_event_record_number + { + chunk.header.last_event_record_number + - chunk.header.first_event_record_number + + 1 + } else { + 0 + }; + + chunks.push(ChunkInfo { + chunk_number: chunk_number as u64, + record_count: safe_record_count.to_string(), + first_record_id: chunk.header.first_event_record_id.to_string(), + last_record_id: chunk.header.last_event_record_id.to_string(), + }); + } + Err(_) => continue, + } + } + Err(_) => continue, + } + } + + let file_info = FileInfo { + total_chunks: chunks.len(), + chunks, + first_chunk: header.first_chunk_number, + last_chunk: header.last_chunk_number, + next_record_id: header.next_record_id.to_string(), + is_dirty: header.flags.contains(evtx::HeaderFlags::DIRTY), + is_full: header.flags.contains(evtx::HeaderFlags::FULL), + }; + + serde_wasm_bindgen::to_value(&file_info) + .map_err(|e| JsError::new(&format!("Failed to serialize file info: {}", e))) + } + + /// Parse all records in the file + #[wasm_bindgen] + pub fn parse_all(&self) -> Result { + self.parse_with_limit(None) + } + + /// Parse records with an optional limit + #[wasm_bindgen] + pub fn parse_with_limit(&self, limit: Option) -> Result { + let cursor = Cursor::new(&self.data); + let settings = ParserSettings::default() + .separate_json_attributes(true) // This might help with the structure + .indent(false); + + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))? + .with_configuration(settings); + + let mut records = Vec::new(); + let mut errors = Vec::new(); + + // Use records_json_value iterator for JSON values + for (idx, record) in parser.records_json_value().enumerate() { + if let Some(limit) = limit { + if records.len() >= limit { + break; + } + } + + match record { + Ok(record_data) => { + // The record_data.data already contains the full event structure + records.push(record_data.data); + } + Err(e) => errors.push(format!("Record {} error: {}", idx, e)), + } + } + + // Count chunks separately + let cursor2 = Cursor::new(&self.data); + let mut parser2 = EvtxParser::from_read_seek(cursor2) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + let chunk_count = parser2.chunks().count(); + + let result = ParseResult { + total_records: records.len(), + records, + chunk_count, + errors, + }; + + serde_wasm_bindgen::to_value(&result) + .map_err(|e| JsError::new(&format!("Failed to serialize result: {}", e))) + } + + /// Parse a specific chunk + #[wasm_bindgen] + pub fn parse_chunk(&self, chunk_index: usize) -> Result { + let cursor = Cursor::new(&self.data); + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + + let mut records = Vec::new(); + let mut errors = Vec::new(); + + for (idx, chunk) in parser.chunks().enumerate() { + if idx != chunk_index { + continue; + } + + match chunk { + Ok(mut chunk_data) => { + let chunk_settings = ParserSettings::default() + .separate_json_attributes(true) + .indent(false); + match chunk_data.parse(std::sync::Arc::new(chunk_settings)) { + Ok(mut chunk) => { + for record in chunk.iter() { + match record { + Ok(record_data) => { + match record_data.into_json_value() { + Ok(json_record) => { + // Use the full data structure + records.push(json_record.data); + } + Err(e) => { + errors.push(format!("Record JSON error: {}", e)) + } + } + } + Err(e) => errors.push(format!("Record error: {}", e)), + } + } + } + Err(e) => errors.push(format!("Chunk parse error: {}", e)), + } + } + Err(e) => errors.push(format!("Chunk error: {}", e)), + } + + break; + } + + let result = ParseResult { + total_records: records.len(), + records, + chunk_count: 1, + errors, + }; + + serde_wasm_bindgen::to_value(&result) + .map_err(|e| JsError::new(&format!("Failed to serialize result: {}", e))) + } + + /// Get a specific record by its ID + #[wasm_bindgen] + pub fn get_record_by_id(&self, record_id: u64) -> Result { + let cursor = Cursor::new(&self.data); + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + + for record in parser.records_json_value() { + match record { + Ok(record_data) => { + if record_data.event_record_id == record_id { + return serde_wasm_bindgen::to_value(&record_data.data).map_err(|e| { + JsError::new(&format!("Failed to serialize record: {}", e)) + }); + } + } + Err(_) => continue, + } + } + + Err(JsError::new(&format!( + "Record with ID {} not found", + record_id + ))) + } + + /// Parse records from a specific chunk with offset/limit. + /// `chunk_index` – zero-based index of the chunk. + /// `start` – zero-based record offset within the chunk to begin at. + /// `limit` – maximum number of records to return (0 = no limit). + #[wasm_bindgen] + pub fn parse_chunk_records( + &self, + chunk_index: usize, + start: usize, + limit: Option, + ) -> Result { + let cursor = Cursor::new(&self.data); + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + + let mut records = Vec::new(); + let mut errors = Vec::new(); + + for (idx, chunk) in parser.chunks().enumerate() { + if idx != chunk_index { + continue; + } + + match chunk { + Ok(mut chunk_data) => { + let chunk_settings = ParserSettings::default() + .separate_json_attributes(true) + .indent(false); + match chunk_data.parse(std::sync::Arc::new(chunk_settings)) { + Ok(mut chunk) => { + for (rec_idx, record) in chunk.iter().enumerate() { + if rec_idx < start { + continue; + } + + if let Some(max) = limit { + if records.len() >= max { + break; + } + } + + match record { + Ok(record_data) => match record_data.into_json_value() { + Ok(json_record) => records.push(json_record.data), + Err(e) => errors.push(format!("Record JSON error: {}", e)), + }, + Err(e) => errors.push(format!("Record error: {}", e)), + } + } + } + Err(e) => errors.push(format!("Chunk parse error: {}", e)), + } + } + Err(e) => errors.push(format!("Chunk error: {}", e)), + } + + break; // Only process the requested chunk + } + + let result = ParseResult { + total_records: records.len(), + records, + chunk_count: 1, + errors, + }; + + serde_wasm_bindgen::to_value(&result) + .map_err(|e| JsError::new(&format!("Failed to serialize result: {}", e))) + } + + /// Serialise a single chunk into Arrow IPC format (Stream, single batch) + /// Returns an object with the binary IPC bytes and the row count. + #[wasm_bindgen] + pub fn chunk_arrow_ipc(&self, chunk_index: usize) -> Result { + // Parse requested chunk similar to `parse_chunk_records` but build Arrow arrays. + use arrow2::array::Array; // trait for Arc + + let cursor = Cursor::new(&self.data); + let mut parser = EvtxParser::from_read_seek(cursor) + .map_err(|e| JsError::new(&format!("Failed to create parser: {}", e)))?; + + // Prepare mutable builders for each column + let mut eid_builder = MutablePrimitiveArray::::new(); + let mut level_builder = MutablePrimitiveArray::::new(); + let mut provider_builder = MutableUtf8Array::::new(); + let mut channel_builder = MutableUtf8Array::::new(); + let mut raw_builder = MutableUtf8Array::::new(); + + let mut found = false; + + for (idx, chunk) in parser.chunks().enumerate() { + if idx != chunk_index { + continue; + } + + found = true; + match chunk { + Ok(mut chunk_data) => { + let chunk_settings = ParserSettings::default() + .separate_json_attributes(true) + .indent(false); + match chunk_data.parse(std::sync::Arc::new(chunk_settings)) { + Ok(mut chunk) => { + for record_res in chunk.iter() { + if let Ok(record_data) = record_res { + match record_data.into_json_value() { + Ok(json_record) => { + let rec = json_record.data; + + // EventID + let eid_opt: Option = rec + .get("Event") + .and_then(|v| v.get("System")) + .and_then(|sys| sys.get("EventID")) + .and_then(|eid| { + if eid.is_string() { + eid.as_str()?.parse::().ok() + } else if eid.is_number() { + eid.as_i64().map(|v| v as i32) + } else if eid.is_object() { + eid.get("#text") + .and_then(|t| t.as_str()) + .and_then(|s| s.parse::().ok()) + } else { + None + } + }); + eid_builder.push(eid_opt); + + // Level + let lvl: i32 = rec + .get("Event") + .and_then(|v| v.get("System")) + .and_then(|sys| sys.get("Level")) + .and_then(|l| l.as_i64()) + .map(|v| v as i32) + .unwrap_or(4); + level_builder.push(Some(lvl)); + + // Provider name + let provider_name: String = rec + .get("Event") + .and_then(|v| v.get("System")) + .and_then(|sys| { + sys.get("Provider") + .and_then(|p| p.get("Name")) + .or_else(|| { + sys.get("Provider_attributes") + .and_then(|p| p.get("Name")) + }) + }) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + provider_builder.push(Some(provider_name.as_str())); + + // Channel + let channel: String = rec + .get("Event") + .and_then(|v| v.get("System")) + .and_then(|sys| sys.get("Channel")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned(); + channel_builder.push(Some(channel.as_str())); + + // Raw JSON + let raw_str = serde_json::to_string(&rec) + .unwrap_or_else(|_| "{}".to_string()); + raw_builder.push(Some(raw_str.as_str())); + } + Err(_) => continue, + } + } + } + } + Err(e) => { + return Err(JsError::new(&format!("Chunk parse error: {}", e))); + } + } + } + Err(e) => { + return Err(JsError::new(&format!("Chunk error: {}", e))); + } + } + break; + } + + if !found { + return Err(JsError::new(&format!( + "Chunk index {} out of range", + chunk_index + ))); + } + + // Finalise Arrow arrays + let eid_array: Box = { + let mut b = eid_builder; + b.as_box() + }; + let level_array: Box = { + let mut b = level_builder; + b.as_box() + }; + let provider_array: Box = { + let mut b = provider_builder; + b.as_box() + }; + let channel_array: Box = { + let mut b = channel_builder; + b.as_box() + }; + let raw_array: Box = { + let mut b = raw_builder; + b.as_box() + }; + + // Construct a Chunk holding boxed arrays as required by StreamWriter::write + let batch: Chunk> = Chunk::new(vec![ + eid_array, + level_array, + provider_array, + channel_array, + raw_array, + ]); + + let schema = Schema::from(vec![ + Field::new("EventID", DataType::Int32, true), + Field::new("Level", DataType::Int32, true), + Field::new("Provider", DataType::Utf8, false), + Field::new("Channel", DataType::Utf8, false), + Field::new("Raw", DataType::Utf8, false), + ]); + + use arrow2::io::ipc::write::WriteOptions; + let mut buf = Vec::new(); + { + let write_opts = WriteOptions { compression: None }; + let mut writer = StreamWriter::new(&mut buf, write_opts); + writer + .start(&schema, None) + .map_err(|e| JsError::new(&format!("IPC writer start failed: {}", e)))?; + writer + .write(&batch, None) + .map_err(|e| JsError::new(&format!("IPC write failed: {}", e)))?; + writer + .finish() + .map_err(|e| JsError::new(&format!("IPC writer finish failed: {}", e)))?; + } + + let row_count = batch.len(); + Ok(ArrowChunkIPC { + bytes: buf, + rows: row_count, + }) + } +} + +#[wasm_bindgen] +pub struct ArrowChunkIPC { + bytes: Vec, + rows: usize, +} + +#[wasm_bindgen] +impl ArrowChunkIPC { + #[wasm_bindgen(getter)] + pub fn ipc(&self) -> js_sys::Uint8Array { + js_sys::Uint8Array::from(&self.bytes[..]) + } + + #[wasm_bindgen(getter)] + pub fn rows(&self) -> usize { + self.rows + } +} + +/// Utility function to get basic file info without creating a parser instance +#[wasm_bindgen] +pub fn quick_file_info(data: &[u8]) -> Result { + let parser = EvtxWasmParser::new(data)?; + parser.get_file_info() +} diff --git a/run_viewer.sh b/run_viewer.sh new file mode 100755 index 00000000..2153ae3c --- /dev/null +++ b/run_viewer.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Build the evtx-wasm crate for the browser target and start the viewer dev server. +# +# Usage: +# ./run_viewer.sh # builds in release mode (optimised) and starts Vite +# ./run_viewer.sh --debug # builds with debug symbols for easier tracing +# +# NOTE: Requires `wasm-pack`, `bun` (or npm/yarn) and Vite installed. + +set -euo pipefail + +# -------- settings ---------- +CRATE_DIR="evtx-wasm" # Rust crate path (relative to repo root) +VIEWER_DIR="evtx-wasm/evtx-viewer" # React viewer path +VIEWER_WASM_DIR="$VIEWER_DIR/src/wasm" # Where the TS code expects the generated bindings +OUT_NAME="evtx_wasm" # Base filename for the generated JS/WASM artefacts +# ---------------------------- + +MODE="release" +if [[ ${1:-} == "--debug" || ${1:-} == "-d" ]]; then + MODE="debug" + shift +fi + +echo "📦 Building WASM in $MODE mode..." + +# Clean previous artefacts so we never serve stale code +rm -rf "$VIEWER_WASM_DIR" +rm -rf "$VIEWER_DIR/public/pkg" # remove old public/pkg if present +mkdir -p "$VIEWER_WASM_DIR" + +# Convert to an absolute path so it stays valid after we cd into the crate dir +ABS_VIEWER_WASM_DIR="$(cd "$(dirname "$VIEWER_WASM_DIR")" && pwd)/$(basename "$VIEWER_WASM_DIR")" + +pushd "$CRATE_DIR" >/dev/null + +BUILD_FLAGS=( + --target web # generate browser-compatible bindings + --out-dir "$ABS_VIEWER_WASM_DIR" # place them where the viewer imports from + --out-name "$OUT_NAME" # keep filenames stable +) + +if [[ "$MODE" == "debug" ]]; then + wasm-pack build "${BUILD_FLAGS[@]}" --debug +else + wasm-pack build "${BUILD_FLAGS[@]}" --release +fi + +popd >/dev/null + +echo "✅ WASM build complete – artefacts are in $ABS_VIEWER_WASM_DIR" + +echo "🚀 Starting Vite dev server... (press Ctrl+C to stop)" + +pushd "$VIEWER_DIR" >/dev/null + +# Ensure JS/TS deps are installed (cheap if already up-to-date) +# Make sample available +mkdir -p "$VIEWER_DIR/public/samples" +ln -sf "$(pwd)/samples/security.evtx" "$VIEWER_DIR/public/samples/security.evtx" + +bun install + +# Forward any leftover CLI args to the dev server (e.g. --open, --host) +bun run dev "$@" + +popd >/dev/null diff --git a/src/lib.rs b/src/lib.rs index 5510fa66..65565f72 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ mod macros; extern crate bitflags; pub use evtx_chunk::{EvtxChunk, EvtxChunkData, EvtxChunkHeader, IterChunkRecords}; +pub use evtx_file_header::{EvtxFileHeader, HeaderFlags}; pub use evtx_parser::{EvtxParser, IntoIterChunks, IterChunks, ParserSettings}; pub use evtx_record::{EvtxRecord, EvtxRecordHeader, SerializedEvtxRecord}; pub use json_output::JsonOutput;