diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 73c22add..e42cef14 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,19 +1,19 @@ -{ - "permissions": { - "allow": [ - "Bash(golangci-lint run:*)", - "Bash(git checkout:*)", - "Bash(go build:*)", - "Bash(go test:*)", - "Bash(npm run build:*)", - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git push:*)", - "Bash(task generate:*)", - "Bash(grep:*)", - "Bash(gh pr checks:*)", - "Bash(gh api:*)", - "Bash(git cherry-pick:*)" - ] - } -} +{ + "permissions": { + "allow": [ + "Bash(golangci-lint run:*)", + "Bash(git checkout:*)", + "Bash(go build:*)", + "Bash(go test:*)", + "Bash(npm run build:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)", + "Bash(task generate:*)", + "Bash(grep:*)", + "Bash(gh pr checks:*)", + "Bash(gh api:*)", + "Bash(git cherry-pick:*)" + ] + } +} diff --git a/.github/workflows/build-apiserver.yaml b/.github/workflows/build-apiserver.yaml index b59f549f..c559f099 100644 --- a/.github/workflows/build-apiserver.yaml +++ b/.github/workflows/build-apiserver.yaml @@ -1,77 +1,77 @@ -name: Build and Publish Docker Image - -on: - push: - release: - types: ["published"] - -jobs: - validate-kustomize: - uses: datum-cloud/actions/.github/workflows/validate-kustomize.yaml@v1.9.0 - - publish-container-image: - # No point in trying to build the container image if the deployment - # manifests are invalid. - needs: - - validate-kustomize - permissions: - id-token: write - contents: read - packages: write - attestations: write - uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.9.0 - with: - image-name: activity - secrets: inherit - - publish-ui-container-image: - # Build and publish the Activity UI container image - needs: - - validate-kustomize - permissions: - id-token: write - contents: read - packages: write - attestations: write - uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.9.0 - with: - image-name: activity-ui - context: ui - dockerfile-path: ui/Dockerfile - secrets: inherit - - publish-kustomize-bundles: - # Ensure the kustomize manifests are valid and the container is published - # before we publish the kustomize manifests. We expect publishing the - # kustomize manifests to result in new deployments going out. - needs: - - validate-kustomize - - publish-container-image - permissions: - id-token: write - contents: read - packages: write - uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.9.0 - with: - bundle-name: ghcr.io/datum-cloud/activity-kustomize - bundle-path: config - image-overlays: config/base - image-name: ghcr.io/datum-cloud/activity - secrets: inherit - - publish-ui-kustomize-bundle: - # Publish the UI kustomize bundle separately - needs: - - validate-kustomize - - publish-ui-container-image - permissions: - id-token: write - contents: read - packages: write - uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.9.0 - with: - bundle-name: ghcr.io/datum-cloud/activity-ui-kustomize - bundle-path: config/components/ui - image-overlays: config/components/ui - image-name: ghcr.io/datum-cloud/activity-ui - secrets: inherit +name: Build and Publish Docker Image + +on: + push: + release: + types: ["published"] + +jobs: + validate-kustomize: + uses: datum-cloud/actions/.github/workflows/validate-kustomize.yaml@v1.9.0 + + publish-container-image: + # No point in trying to build the container image if the deployment + # manifests are invalid. + needs: + - validate-kustomize + permissions: + id-token: write + contents: read + packages: write + attestations: write + uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.9.0 + with: + image-name: activity + secrets: inherit + + publish-ui-container-image: + # Build and publish the Activity UI container image + needs: + - validate-kustomize + permissions: + id-token: write + contents: read + packages: write + attestations: write + uses: datum-cloud/actions/.github/workflows/publish-docker.yaml@v1.9.0 + with: + image-name: activity-ui + context: ui + dockerfile-path: ui/Dockerfile + secrets: inherit + + publish-kustomize-bundles: + # Ensure the kustomize manifests are valid and the container is published + # before we publish the kustomize manifests. We expect publishing the + # kustomize manifests to result in new deployments going out. + needs: + - validate-kustomize + - publish-container-image + permissions: + id-token: write + contents: read + packages: write + uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.9.0 + with: + bundle-name: ghcr.io/datum-cloud/activity-kustomize + bundle-path: config + image-overlays: config/base + image-name: ghcr.io/datum-cloud/activity + secrets: inherit + + publish-ui-kustomize-bundle: + # Publish the UI kustomize bundle separately + needs: + - validate-kustomize + - publish-ui-container-image + permissions: + id-token: write + contents: read + packages: write + uses: datum-cloud/actions/.github/workflows/publish-kustomize-bundle.yaml@v1.9.0 + with: + bundle-name: ghcr.io/datum-cloud/activity-ui-kustomize + bundle-path: config/components/ui + image-overlays: config/components/ui + image-name: ghcr.io/datum-cloud/activity-ui + secrets: inherit diff --git a/.github/workflows/publish-ui-npm.yaml b/.github/workflows/publish-ui-npm.yaml index 0d3b52ec..4c10c037 100644 --- a/.github/workflows/publish-ui-npm.yaml +++ b/.github/workflows/publish-ui-npm.yaml @@ -1,14 +1,14 @@ -name: Publish UI to NPM - -on: - push: - branches: - - main - -jobs: - publish: - uses: datum-cloud/actions/.github/workflows/publish-npm-package.yaml@v1.11.0 - with: - package-name: "@datum-cloud/activity-ui" - package-path: ui - secrets: inherit +name: Publish UI to NPM + +on: + push: + branches: + - main + +jobs: + publish: + uses: datum-cloud/actions/.github/workflows/publish-npm-package.yaml@v1.11.0 + with: + package-name: "@datum-cloud/activity-ui" + package-path: ui + secrets: inherit diff --git a/.github/workflows/testing.yaml b/.github/workflows/testing.yaml index a1efd57b..44e1272a 100644 --- a/.github/workflows/testing.yaml +++ b/.github/workflows/testing.yaml @@ -1,14 +1,14 @@ -name: "Execute Golang Tests" -on: - push: - -jobs: - execute-tests: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - - name: Install dependencies - run: go mod download - - name: Execute golang tests - run: go test -timeout 5m ./... +name: "Execute Golang Tests" +on: + push: + +jobs: + execute-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + - name: Install dependencies + run: go mod download + - name: Execute golang tests + run: go test -timeout 5m ./... diff --git a/.gitignore b/.gitignore index 12d656c3..5685bc6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,70 +1,70 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib -bin/ -dist/ - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool -*.out -coverage.txt -coverage.html - -# Go workspace file -go.work - -# Dependency directories -vendor/ - -# IDE specific files -.idea/ -.vscode/ -*.swp -*.swo -*~ - -# OS specific files -.DS_Store -Thumbs.db - -# Build outputs -./activity - -# Kubernetes secrets -*.key -*.crt -*.pem - -# Temporary files -tmp/ -temp/ - -# Local environment files +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +bin/ +dist/ + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +*.out +coverage.txt +coverage.html + +# Go workspace file +go.work + +# Dependency directories +vendor/ + +# IDE specific files +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS specific files +.DS_Store +Thumbs.db + +# Build outputs +./activity + +# Kubernetes secrets +*.key +*.crt +*.pem + +# Temporary files +tmp/ +temp/ + +# Local environment files + +# ClickHouse data +clickhouse-data/ + +# Dashboard build outputs +dashboards/vendor/ +dashboards/jsonnetfile.lock.json +observability/vendor/ +observability/jsonnetfile.lock.json + +# Directory used for the test-infra repo to manage the test-infra environment. +.test-infra + +# Directory used by the taskfile for remote taskfile storage +.task +.infra + +# Node.js +node_modules/ +.pnpm-store/ +.claude/settings.local.json .env .env.local - -# ClickHouse data -clickhouse-data/ - -# Dashboard build outputs -dashboards/vendor/ -dashboards/jsonnetfile.lock.json -observability/vendor/ -observability/jsonnetfile.lock.json - -# Directory used for the test-infra repo to manage the test-infra environment. -.test-infra - -# Directory used by the taskfile for remote taskfile storage -.task -.infra - -# Node.js -node_modules/ -.pnpm-store/ -.claude/settings.local.json diff --git a/CLAUDE.md b/CLAUDE.md index cc99b8e2..9ce174cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,309 +1,309 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -> **Context Optimization**: This file is structured for efficient agent usage. The "Agent Routing" section defines what context each agent needs. When spawning subagents, pass only relevant sections—not the entire file. Sections marked `` are lookup tables; don't include them in agent prompts unless specifically needed. - -## Project Overview - -Activity is a Kubernetes extension that provides queryable audit logs, events, and human-readable activity summaries. It's built as an aggregated API server, making it work natively with kubectl and Kubernetes clients. - -**Module**: `go.miloapis.com/activity` - -## Agent Routing - -**MANDATORY: All implementation work MUST be performed by subagents.** Never directly edit code, configuration, or documentation in the parent conversation. Instead, always delegate to the appropriate specialized agent from the table below. The parent conversation should only coordinate agents, pass context between them, and communicate results to the user. - -Do NOT ask the user which agent to use - pick the appropriate one based on what files or features are being modified. - -| Task Type | Agent | When to Use | -|-----------|-------|-------------| -| UI/Frontend | `datum-platform:frontend-dev` | React, TypeScript, CSS, anything in `ui/` directory | -| Go Backend | `datum-platform:api-dev` | Go code in `cmd/`, `internal/`, `pkg/` directories | -| Infrastructure | `datum-platform:sre` | Kustomize, Dockerfile, CI/CD, `config/` directory, `.infra/` for deployment | -| Tests | `datum-platform:test-engineer` | Writing or fixing Go tests | -| Code Review | `datum-platform:code-reviewer` | After implementation, before committing | -| Documentation | `datum-platform:tech-writer` | README, docs/, guides, API documentation | -| Architecture | `Plan` | Designing new features or significant refactors | -| Exploration | `Explore` | Understanding codebase structure or finding code | - -**Key principles:** -- **Always use subagents** — never write code, edit files, or run build/test commands directly in the parent conversation -- Use agents proactively without being asked -- For multi-step tasks, use the appropriate agent for each step (launch independent agents in parallel when possible) -- After making code changes, always use `code-reviewer` to validate -- For UI changes, run `npm run build` and `npm run test:e2e` to verify -- **Always test infrastructure changes in a test environment before opening a PR** - Deploy to the test-infra KIND cluster (`task test-infra:cluster-up`) and verify resources work correctly before pushing changes to staging/production repos -- **Use Telepresence for debugging staging issues** - When investigating bugs that only reproduce in staging, intercept the service and run it locally with `task test-infra:telepresence:intercept SERVICE=`. See "Remote Debugging with Telepresence" section. - -### Agent Context Requirements - -Each agent only needs specific context. When spawning agents, pass minimal relevant info in prompts—don't repeat the entire CLAUDE.md: - -| Agent | Required Context | Skip (don't include in prompt) | -|-------|-----------------|--------------------------------| -| `frontend-dev` | UI commands, file paths in `ui/` | Go architecture, ClickHouse, NATS, data pipeline | -| `api-dev` | Go patterns, API resource types, key directories | UI commands, dev environment setup, migrations | -| `sre` | Config structure, build commands, deployment | Code architecture details, CEL patterns | -| `test-engineer` | Test commands, package being tested | Full architecture, deployment, UI | -| `Explore` | Key directories, architecture overview | Build commands, dev setup, deployment | -| `code-reviewer` | Architecture, multi-tenancy model, conventions | Dev environment, build commands | -| `tech-writer` | API resources, architecture overview | Implementation details, build commands | - -### Agent Output Guidelines - -Agents should return **concise summaries** to minimize context bloat in the parent conversation: - -| Agent | Return | Don't Return | -|-------|--------|--------------| -| `Explore` | File paths + 1-line descriptions | Full file contents, extensive code quotes | -| `api-dev` | What was changed + file paths | Full diffs, unchanged code | -| `frontend-dev` | Components modified + any build errors | Full file contents | -| `code-reviewer` | Numbered findings list with file:line refs | Full code blocks for context | -| `test-engineer` | Pass/fail summary + failure messages only | Full test output, passing test details | -| `sre` | Changed manifests + deployment notes | Full YAML contents | - -### Multi-Step Task Decomposition - -For complex tasks, decompose to minimize per-agent context: - -1. **Explore first** (use `model: "haiku"`): Find relevant files → return only paths -2. **Plan if needed**: Design approach → return bullet points only -3. **Implement** (sonnet): Work on specific files identified in step 1 -4. **Review**: Check only the changed files - -**Critical**: Pass only what's needed between steps. Don't re-explore what's already known. - -## Build and Development Commands - -All development tasks use [Task](https://taskfile.dev). Run `task --list` to see all available commands. - -### Building - -```bash -task build # Build the activity binary to bin/activity -task dev:build # Build container image (ghcr.io/datum-cloud/activity:dev) -``` - -### Testing - -```bash -go test ./... # Run all Go tests -go test ./internal/cel/... # Run tests in a specific package -go test -run TestName ./... # Run a specific test -``` - -### Code Generation - -```bash -task generate # Run all code generation (OpenAPI, RBAC, migrations, docs) -task generate:openapi # Generate Kubernetes OpenAPI definitions -task generate:rbac # Generate RBAC manifests from kubebuilder annotations -task generate:docs # Generate API reference documentation -``` - -### UI Component Library - -The UI is a React component library in `/ui`: - -```bash -cd ui && npm install && npm run build # Build the library -cd ui && npm run lint # Lint TypeScript -cd ui && npm run type-check # Type check -``` - -Or via Task: -```bash -task ui:build # Build component library -task ui:example:dev # Run example app in dev mode -``` - -## Architecture - -### Components - -- **activity-apiserver**: Kubernetes aggregated API server that handles queries and Watch streams -- **activity-processor**: Processes audit logs/events through ActivityPolicy rules to generate Activities -- **activity-controller-manager**: Manages ActivityPolicy lifecycle and status -- **kubectl-activity**: CLI plugin for command-line querying -- **activity-ui**: React component library for web interfaces - -### Data Pipeline - -1. **Audit logs/Events** → Published to NATS JetStream by control plane -2. **Vector** → Receives from NATS, routes to ClickHouse and back to NATS for processing -3. **activity-processor** → Consumes audit events, applies ActivityPolicy rules, produces Activities -4. **ClickHouse** → Stores audit logs, events, and activities for long-term querying -5. **etcd** → Stores ActivityPolicy resources with Watch support - -### API Resources (`activity.miloapis.com/v1alpha1`) - -| Resource | Type | Purpose | -|----------|------|---------| -| AuditLogQuery | Ephemeral | Execute audit log searches | -| AuditLogFacetsQuery | Ephemeral | Get distinct values for autocomplete | -| Activity | Read-only | Query translated activity records | -| ActivityFacetQuery | Ephemeral | Get distinct activity field values | -| ActivityPolicy | Persistent | Define translation rules (CEL-based) | -| PolicyPreview | Ephemeral | Test policies against sample inputs | -| EventQuery | Ephemeral | Query cluster events | -| EventFacetQuery | Ephemeral | Get distinct event field values | - -### Key Directories - -- `cmd/activity/` - Main binary entrypoint (subcommands: apiserver, processor, controller-manager) -- `internal/apiserver/` - Aggregated API server implementation -- `internal/storage/` - ClickHouse storage backend -- `internal/cel/` - CEL expression engine for ActivityPolicy rules -- `internal/processor/` - Activity translation processor -- `internal/controller/` - Kubernetes controller for ActivityPolicy -- `internal/watch/` - Watch API implementation via NATS consumers -- `pkg/apis/activity/v1alpha1/` - API type definitions -- `pkg/mcp/` - MCP (Model Context Protocol) server implementation -- `config/` - Kustomize deployment manifests -- `migrations/` - ClickHouse schema migrations -- `.infra/` - Cloned infra repo for deployment configuration (see Infrastructure Management below) - -### Infrastructure Management - -The `.infra/` directory contains a clone of the `datum-cloud/infra` repository. **Always use this folder for managing Activity's deployment infrastructure**, including: - -- Flux Kustomizations -- Environment-specific patches (staging/production) -- Secret configurations -- Dependencies and deployment ordering - -**Key paths in `.infra/`:** - -| Path | Purpose | -|------|---------| -| `.infra/apps/activity-system/base/` | Base Flux Kustomizations for all Activity components | -| `.infra/apps/activity-system/overlays/staging/` | Staging-specific patches and resources | -| `.infra/apps/activity-system/overlays/production/` | Production-specific patches | -| `.infra/clusters/staging/apps/activity-system.yaml` | Staging cluster entry point | -| `.infra/clusters/production/apps/activity-system.yaml` | Production cluster entry point | - -**Workflow for infrastructure changes:** - -1. Make changes in `.infra/apps/activity-system/` -2. Commit and push to `datum-cloud/infra` repo (not this repo) -3. FluxCD will reconcile changes to the cluster - -**Important:** The `.infra/` folder is gitignored from this repo. Changes must be committed to the infra repo separately. - -## Development Environment - -### Lightweight Dev Setup (single-replica, minimal resources) - -```bash -task dev:setup # Full setup: cluster + dependencies + deploy -task dev:redeploy # Quick rebuild and redeploy after code changes -``` - -### Full Test Environment (HA with S3 storage) - -```bash -task test:setup # Full HA setup with 3-replica ClickHouse -task test:redeploy # Quick rebuild and redeploy -``` - -### Cluster Access - -```bash -task test-infra:kubectl -- # Run kubectl against dev cluster -task test-infra:kubectl -- get pods -n activity-system -task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f -``` - -### Database Migrations - -```bash -task migrations:new NAME=description # Create new migration file -task migrations:generate # Generate ConfigMap from migrations -task migrations:cluster:verify # Verify schema in cluster -``` - -### Remote Debugging with Telepresence - -When debugging issues in staging, use Telepresence to intercept a service and run it locally with full access to cluster resources (NATS, ClickHouse, etcd, milo). - -```bash -# Install Telepresence CLI (one-time) -task test-infra:telepresence:install - -# Connect to staging cluster -KUBECONFIG=~/.kube/gke-staging task test-infra:telepresence:connect - -# Intercept a service to run locally -task test-infra:telepresence:intercept SERVICE=activity-apiserver NAMESPACE=activity-system PORT=6443 - -# Load environment variables from intercepted service -source /tmp/telepresence-activity-apiserver.env - -# Run the service locally with debugger -go run ./cmd/activity apiserver --secure-port=6443 - -# When done, release the intercept -telepresence leave activity-apiserver -telepresence quit -``` - -**Available services to intercept:** -- `activity-apiserver` (port 6443) - API server handling queries -- `activity-processor` (port 8080) - Event processing pipeline -- `activity-controller-manager` (port 8080) - ActivityPolicy controller - -**When to use Telepresence:** -- Debugging issues that only reproduce in staging with real data -- Testing changes against production-like NATS streams and ClickHouse data -- Investigating connectivity or configuration issues - -## Multi-Tenancy Model - -Data is scoped by tenant: -- **Platform**: All data across all tenants -- **Organization**: Data within a specific organization -- **Project**: Data within a specific project -- **User**: Actions performed by a specific user - -Scopes are NOT hierarchically inclusive - query each scope directly. - -## ActivityPolicy Translation - -ActivityPolicy resources define how audit logs/events are translated into human-readable Activities using CEL expressions: - -```yaml -apiVersion: activity.miloapis.com/v1alpha1 -kind: ActivityPolicy -spec: - resource: - apiGroup: networking.datumapis.com - kind: HTTPProxy - auditRules: - - match: "audit.verb == 'create'" - summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" -``` - -## Stable Codebase Facts - -These locations rarely change—agents should use these directly without re-exploring: - -| What | Location | Notes | -|------|----------|-------| -| API type definitions | `pkg/apis/activity/v1alpha1/` | All CRD types live here | -| Storage implementations | `internal/storage/` | ClickHouse queries | -| CEL expression logic | `internal/cel/` | Policy matching engine | -| UI components | `ui/src/components/` | React component library | -| API server handlers | `internal/apiserver/` | REST handlers for each resource | -| Kustomize base | `config/base/` | Core deployment manifests | -| Kustomize components | `config/components/` | Optional features (ui, vector, etc.) | -| Database migrations | `migrations/` | ClickHouse schema files | - -## Technology Stack - -- **Go 1.25+** - Backend implementation -- **NATS JetStream** - Durable event streaming -- **Vector** - Data pipeline routing -- **ClickHouse** - Analytics storage -- **etcd** - Policy persistence -- **React/TypeScript** - UI components +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +> **Context Optimization**: This file is structured for efficient agent usage. The "Agent Routing" section defines what context each agent needs. When spawning subagents, pass only relevant sections—not the entire file. Sections marked `` are lookup tables; don't include them in agent prompts unless specifically needed. + +## Project Overview + +Activity is a Kubernetes extension that provides queryable audit logs, events, and human-readable activity summaries. It's built as an aggregated API server, making it work natively with kubectl and Kubernetes clients. + +**Module**: `go.miloapis.com/activity` + +## Agent Routing + +**MANDATORY: All implementation work MUST be performed by subagents.** Never directly edit code, configuration, or documentation in the parent conversation. Instead, always delegate to the appropriate specialized agent from the table below. The parent conversation should only coordinate agents, pass context between them, and communicate results to the user. + +Do NOT ask the user which agent to use - pick the appropriate one based on what files or features are being modified. + +| Task Type | Agent | When to Use | +|-----------|-------|-------------| +| UI/Frontend | `datum-platform:frontend-dev` | React, TypeScript, CSS, anything in `ui/` directory | +| Go Backend | `datum-platform:api-dev` | Go code in `cmd/`, `internal/`, `pkg/` directories | +| Infrastructure | `datum-platform:sre` | Kustomize, Dockerfile, CI/CD, `config/` directory, `.infra/` for deployment | +| Tests | `datum-platform:test-engineer` | Writing or fixing Go tests | +| Code Review | `datum-platform:code-reviewer` | After implementation, before committing | +| Documentation | `datum-platform:tech-writer` | README, docs/, guides, API documentation | +| Architecture | `Plan` | Designing new features or significant refactors | +| Exploration | `Explore` | Understanding codebase structure or finding code | + +**Key principles:** +- **Always use subagents** — never write code, edit files, or run build/test commands directly in the parent conversation +- Use agents proactively without being asked +- For multi-step tasks, use the appropriate agent for each step (launch independent agents in parallel when possible) +- After making code changes, always use `code-reviewer` to validate +- For UI changes, run `npm run build` and `npm run test:e2e` to verify +- **Always test infrastructure changes in a test environment before opening a PR** - Deploy to the test-infra KIND cluster (`task test-infra:cluster-up`) and verify resources work correctly before pushing changes to staging/production repos +- **Use Telepresence for debugging staging issues** - When investigating bugs that only reproduce in staging, intercept the service and run it locally with `task test-infra:telepresence:intercept SERVICE=`. See "Remote Debugging with Telepresence" section. + +### Agent Context Requirements + +Each agent only needs specific context. When spawning agents, pass minimal relevant info in prompts—don't repeat the entire CLAUDE.md: + +| Agent | Required Context | Skip (don't include in prompt) | +|-------|-----------------|--------------------------------| +| `frontend-dev` | UI commands, file paths in `ui/` | Go architecture, ClickHouse, NATS, data pipeline | +| `api-dev` | Go patterns, API resource types, key directories | UI commands, dev environment setup, migrations | +| `sre` | Config structure, build commands, deployment | Code architecture details, CEL patterns | +| `test-engineer` | Test commands, package being tested | Full architecture, deployment, UI | +| `Explore` | Key directories, architecture overview | Build commands, dev setup, deployment | +| `code-reviewer` | Architecture, multi-tenancy model, conventions | Dev environment, build commands | +| `tech-writer` | API resources, architecture overview | Implementation details, build commands | + +### Agent Output Guidelines + +Agents should return **concise summaries** to minimize context bloat in the parent conversation: + +| Agent | Return | Don't Return | +|-------|--------|--------------| +| `Explore` | File paths + 1-line descriptions | Full file contents, extensive code quotes | +| `api-dev` | What was changed + file paths | Full diffs, unchanged code | +| `frontend-dev` | Components modified + any build errors | Full file contents | +| `code-reviewer` | Numbered findings list with file:line refs | Full code blocks for context | +| `test-engineer` | Pass/fail summary + failure messages only | Full test output, passing test details | +| `sre` | Changed manifests + deployment notes | Full YAML contents | + +### Multi-Step Task Decomposition + +For complex tasks, decompose to minimize per-agent context: + +1. **Explore first** (use `model: "haiku"`): Find relevant files → return only paths +2. **Plan if needed**: Design approach → return bullet points only +3. **Implement** (sonnet): Work on specific files identified in step 1 +4. **Review**: Check only the changed files + +**Critical**: Pass only what's needed between steps. Don't re-explore what's already known. + +## Build and Development Commands + +All development tasks use [Task](https://taskfile.dev). Run `task --list` to see all available commands. + +### Building + +```bash +task build # Build the activity binary to bin/activity +task dev:build # Build container image (ghcr.io/datum-cloud/activity:dev) +``` + +### Testing + +```bash +go test ./... # Run all Go tests +go test ./internal/cel/... # Run tests in a specific package +go test -run TestName ./... # Run a specific test +``` + +### Code Generation + +```bash +task generate # Run all code generation (OpenAPI, RBAC, migrations, docs) +task generate:openapi # Generate Kubernetes OpenAPI definitions +task generate:rbac # Generate RBAC manifests from kubebuilder annotations +task generate:docs # Generate API reference documentation +``` + +### UI Component Library + +The UI is a React component library in `/ui`: + +```bash +cd ui && npm install && npm run build # Build the library +cd ui && npm run lint # Lint TypeScript +cd ui && npm run type-check # Type check +``` + +Or via Task: +```bash +task ui:build # Build component library +task ui:example:dev # Run example app in dev mode +``` + +## Architecture + +### Components + +- **activity-apiserver**: Kubernetes aggregated API server that handles queries and Watch streams +- **activity-processor**: Processes audit logs/events through ActivityPolicy rules to generate Activities +- **activity-controller-manager**: Manages ActivityPolicy lifecycle and status +- **kubectl-activity**: CLI plugin for command-line querying +- **activity-ui**: React component library for web interfaces + +### Data Pipeline + +1. **Audit logs/Events** → Published to NATS JetStream by control plane +2. **Vector** → Receives from NATS, routes to ClickHouse and back to NATS for processing +3. **activity-processor** → Consumes audit events, applies ActivityPolicy rules, produces Activities +4. **ClickHouse** → Stores audit logs, events, and activities for long-term querying +5. **etcd** → Stores ActivityPolicy resources with Watch support + +### API Resources (`activity.miloapis.com/v1alpha1`) + +| Resource | Type | Purpose | +|----------|------|---------| +| AuditLogQuery | Ephemeral | Execute audit log searches | +| AuditLogFacetsQuery | Ephemeral | Get distinct values for autocomplete | +| Activity | Read-only | Query translated activity records | +| ActivityFacetQuery | Ephemeral | Get distinct activity field values | +| ActivityPolicy | Persistent | Define translation rules (CEL-based) | +| PolicyPreview | Ephemeral | Test policies against sample inputs | +| EventQuery | Ephemeral | Query cluster events | +| EventFacetQuery | Ephemeral | Get distinct event field values | + +### Key Directories + +- `cmd/activity/` - Main binary entrypoint (subcommands: apiserver, processor, controller-manager) +- `internal/apiserver/` - Aggregated API server implementation +- `internal/storage/` - ClickHouse storage backend +- `internal/cel/` - CEL expression engine for ActivityPolicy rules +- `internal/processor/` - Activity translation processor +- `internal/controller/` - Kubernetes controller for ActivityPolicy +- `internal/watch/` - Watch API implementation via NATS consumers +- `pkg/apis/activity/v1alpha1/` - API type definitions +- `pkg/mcp/` - MCP (Model Context Protocol) server implementation +- `config/` - Kustomize deployment manifests +- `migrations/` - ClickHouse schema migrations +- `.infra/` - Cloned infra repo for deployment configuration (see Infrastructure Management below) + +### Infrastructure Management + +The `.infra/` directory contains a clone of the `datum-cloud/infra` repository. **Always use this folder for managing Activity's deployment infrastructure**, including: + +- Flux Kustomizations +- Environment-specific patches (staging/production) +- Secret configurations +- Dependencies and deployment ordering + +**Key paths in `.infra/`:** + +| Path | Purpose | +|------|---------| +| `.infra/apps/activity-system/base/` | Base Flux Kustomizations for all Activity components | +| `.infra/apps/activity-system/overlays/staging/` | Staging-specific patches and resources | +| `.infra/apps/activity-system/overlays/production/` | Production-specific patches | +| `.infra/clusters/staging/apps/activity-system.yaml` | Staging cluster entry point | +| `.infra/clusters/production/apps/activity-system.yaml` | Production cluster entry point | + +**Workflow for infrastructure changes:** + +1. Make changes in `.infra/apps/activity-system/` +2. Commit and push to `datum-cloud/infra` repo (not this repo) +3. FluxCD will reconcile changes to the cluster + +**Important:** The `.infra/` folder is gitignored from this repo. Changes must be committed to the infra repo separately. + +## Development Environment + +### Lightweight Dev Setup (single-replica, minimal resources) + +```bash +task dev:setup # Full setup: cluster + dependencies + deploy +task dev:redeploy # Quick rebuild and redeploy after code changes +``` + +### Full Test Environment (HA with S3 storage) + +```bash +task test:setup # Full HA setup with 3-replica ClickHouse +task test:redeploy # Quick rebuild and redeploy +``` + +### Cluster Access + +```bash +task test-infra:kubectl -- # Run kubectl against dev cluster +task test-infra:kubectl -- get pods -n activity-system +task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f +``` + +### Database Migrations + +```bash +task migrations:new NAME=description # Create new migration file +task migrations:generate # Generate ConfigMap from migrations +task migrations:cluster:verify # Verify schema in cluster +``` + +### Remote Debugging with Telepresence + +When debugging issues in staging, use Telepresence to intercept a service and run it locally with full access to cluster resources (NATS, ClickHouse, etcd, milo). + +```bash +# Install Telepresence CLI (one-time) +task test-infra:telepresence:install + +# Connect to staging cluster +KUBECONFIG=~/.kube/gke-staging task test-infra:telepresence:connect + +# Intercept a service to run locally +task test-infra:telepresence:intercept SERVICE=activity-apiserver NAMESPACE=activity-system PORT=6443 + +# Load environment variables from intercepted service +source /tmp/telepresence-activity-apiserver.env + +# Run the service locally with debugger +go run ./cmd/activity apiserver --secure-port=6443 + +# When done, release the intercept +telepresence leave activity-apiserver +telepresence quit +``` + +**Available services to intercept:** +- `activity-apiserver` (port 6443) - API server handling queries +- `activity-processor` (port 8080) - Event processing pipeline +- `activity-controller-manager` (port 8080) - ActivityPolicy controller + +**When to use Telepresence:** +- Debugging issues that only reproduce in staging with real data +- Testing changes against production-like NATS streams and ClickHouse data +- Investigating connectivity or configuration issues + +## Multi-Tenancy Model + +Data is scoped by tenant: +- **Platform**: All data across all tenants +- **Organization**: Data within a specific organization +- **Project**: Data within a specific project +- **User**: Actions performed by a specific user + +Scopes are NOT hierarchically inclusive - query each scope directly. + +## ActivityPolicy Translation + +ActivityPolicy resources define how audit logs/events are translated into human-readable Activities using CEL expressions: + +```yaml +apiVersion: activity.miloapis.com/v1alpha1 +kind: ActivityPolicy +spec: + resource: + apiGroup: networking.datumapis.com + kind: HTTPProxy + auditRules: + - match: "audit.verb == 'create'" + summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" +``` + +## Stable Codebase Facts + +These locations rarely change—agents should use these directly without re-exploring: + +| What | Location | Notes | +|------|----------|-------| +| API type definitions | `pkg/apis/activity/v1alpha1/` | All CRD types live here | +| Storage implementations | `internal/storage/` | ClickHouse queries | +| CEL expression logic | `internal/cel/` | Policy matching engine | +| UI components | `ui/src/components/` | React component library | +| API server handlers | `internal/apiserver/` | REST handlers for each resource | +| Kustomize base | `config/base/` | Core deployment manifests | +| Kustomize components | `config/components/` | Optional features (ui, vector, etc.) | +| Database migrations | `migrations/` | ClickHouse schema files | + +## Technology Stack + +- **Go 1.25+** - Backend implementation +- **NATS JetStream** - Durable event streaming +- **Vector** - Data pipeline routing +- **ClickHouse** - Analytics storage +- **etcd** - Policy persistence +- **React/TypeScript** - UI components diff --git a/Dockerfile b/Dockerfile index 9152ff72..f2771c01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,39 +1,39 @@ -# Build stage -FROM golang:1.25-alpine AS builder - -# Build arguments for version injection -ARG VERSION=dev -ARG GIT_COMMIT=unknown -ARG GIT_TREE_STATE=unknown -ARG BUILD_DATE=unknown - -WORKDIR /workspace - -# Copy go mod files -COPY go.mod go.mod -COPY go.sum go.sum - -# Cache dependencies -RUN go mod download - -# Copy source code -COPY cmd/ cmd/ -COPY pkg/ pkg/ -COPY internal/ internal/ - -# Build the binary with Activity-specific version information -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ - -ldflags="-X 'go.miloapis.com/activity/internal/version.Version=${VERSION}' \ - -X 'go.miloapis.com/activity/internal/version.GitCommit=${GIT_COMMIT}' \ - -X 'go.miloapis.com/activity/internal/version.GitTreeState=${GIT_TREE_STATE}' \ - -X 'go.miloapis.com/activity/internal/version.BuildDate=${BUILD_DATE}'" \ - -a -o activity ./cmd/activity - -# Runtime stage -FROM gcr.io/distroless/static:nonroot - -WORKDIR / -COPY --from=builder /workspace/activity . -USER 65532:65532 - -ENTRYPOINT ["/activity"] +# Build stage +FROM golang:1.25-alpine AS builder + +# Build arguments for version injection +ARG VERSION=dev +ARG GIT_COMMIT=unknown +ARG GIT_TREE_STATE=unknown +ARG BUILD_DATE=unknown + +WORKDIR /workspace + +# Copy go mod files +COPY go.mod go.mod +COPY go.sum go.sum + +# Cache dependencies +RUN go mod download + +# Copy source code +COPY cmd/ cmd/ +COPY pkg/ pkg/ +COPY internal/ internal/ + +# Build the binary with Activity-specific version information +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-X 'go.miloapis.com/activity/internal/version.Version=${VERSION}' \ + -X 'go.miloapis.com/activity/internal/version.GitCommit=${GIT_COMMIT}' \ + -X 'go.miloapis.com/activity/internal/version.GitTreeState=${GIT_TREE_STATE}' \ + -X 'go.miloapis.com/activity/internal/version.BuildDate=${BUILD_DATE}'" \ + -a -o activity ./cmd/activity + +# Runtime stage +FROM gcr.io/distroless/static:nonroot + +WORKDIR / +COPY --from=builder /workspace/activity . +USER 65532:65532 + +ENTRYPOINT ["/activity"] diff --git a/README.md b/README.md index 005ca060..a95d9bd7 100644 --- a/README.md +++ b/README.md @@ -1,76 +1,76 @@ -# Activity - -Ever wonder who changed that production secret? Or need to track down who deleted a deployment last week? Activity -makes it easy to ask questions about what's happening in your Kubernetes clusters. - -## What is this? - -Activity is a Kubernetes extension that lets you query your cluster's audit logs using familiar Kubernetes -tools. Instead of digging through log files, you can use `kubectl` to ask questions like "show me all the deletions in -production" or "who accessed secrets in the last hour?" - -Think of it as a search engine for everything that happens in your cluster. It's built as an aggregated API server, -which means it feels like a natural part of Kubernetes, not a bolt-on tool. - -## Components - -Activity consists of several components that work together: - -- **activity-apiserver**: Kubernetes aggregated API server that processes audit log queries -- **activity-ui**: React component library for building web interfaces -- **kubectl-activity**: kubectl plugin for command-line querying - -## What can it do right now? - -- **Ask powerful questions** using CEL expressions: "Find all secret deletions by users whose name starts with - 'system:'" -- **Filter by what matters**: time ranges, namespaces, actions (create/update/delete), resource types, users, and more -- **Fast queries** thanks to a high-performance ClickHouse backend with smart indexing -- **Works like Kubernetes** because it's built as an aggregated API server—use `kubectl` or any Kubernetes client -- **Multi-tenant by design** so teams can only see their own activity - -## What's coming next? - -We're working on some exciting features to make activity tracking even more powerful: - -**Human-readable activity summaries** - Right now, you get raw audit events. Soon, you'll see friendly descriptions like -"Alice deleted the production-db secret in the billing namespace" instead of decoding JSON structures. - -**Flexible, dynamic descriptions** - We're building a system that lets you define how events should be described for -your organization. Want to call them "changes" instead of "updates"? Prefer different phrasing for different teams? No -problem—and you won't need to re-process historical data to make changes. - -These features are part of our vision to transform raw audit logs into clear, actionable insights that anyone can -understand. You can follow the detailed roadmap in [this enhancement -proposal](https://github.com/datum-cloud/enhancements/issues/469). - -## Documentation - -- [Architecture Overview](docs/architecture/README.md) - System design and components -- [API Reference](docs/api.md) - API specifications - -## Who is this for? - -- **Platform teams** who need to understand cluster activity across multiple tenants -- **Security teams** investigating incidents or building compliance reports -- **Developers** debugging "who changed what" questions -- **Anyone** who's ever wished Kubernetes audit logs were easier to query - -## Prerequisites - -**For users:** -- Kubernetes 1.34+ cluster -- kubectl configured to access your cluster - -**For developers:** -- Go 1.24.0 or later -- [Task](https://taskfile.dev) for development workflows -- Docker for building container images - -## License - -See [LICENSE](LICENSE) for details. - ---- - -**Questions or feedback?** Open an issue—we're here to help! +# Activity + +Ever wonder who changed that production secret? Or need to track down who deleted a deployment last week? Activity +makes it easy to ask questions about what's happening in your Kubernetes clusters. + +## What is this? + +Activity is a Kubernetes extension that lets you query your cluster's audit logs using familiar Kubernetes +tools. Instead of digging through log files, you can use `kubectl` to ask questions like "show me all the deletions in +production" or "who accessed secrets in the last hour?" + +Think of it as a search engine for everything that happens in your cluster. It's built as an aggregated API server, +which means it feels like a natural part of Kubernetes, not a bolt-on tool. + +## Components + +Activity consists of several components that work together: + +- **activity-apiserver**: Kubernetes aggregated API server that processes audit log queries +- **activity-ui**: React component library for building web interfaces +- **kubectl-activity**: kubectl plugin for command-line querying + +## What can it do right now? + +- **Ask powerful questions** using CEL expressions: "Find all secret deletions by users whose name starts with + 'system:'" +- **Filter by what matters**: time ranges, namespaces, actions (create/update/delete), resource types, users, and more +- **Fast queries** thanks to a high-performance ClickHouse backend with smart indexing +- **Works like Kubernetes** because it's built as an aggregated API server—use `kubectl` or any Kubernetes client +- **Multi-tenant by design** so teams can only see their own activity + +## What's coming next? + +We're working on some exciting features to make activity tracking even more powerful: + +**Human-readable activity summaries** - Right now, you get raw audit events. Soon, you'll see friendly descriptions like +"Alice deleted the production-db secret in the billing namespace" instead of decoding JSON structures. + +**Flexible, dynamic descriptions** - We're building a system that lets you define how events should be described for +your organization. Want to call them "changes" instead of "updates"? Prefer different phrasing for different teams? No +problem—and you won't need to re-process historical data to make changes. + +These features are part of our vision to transform raw audit logs into clear, actionable insights that anyone can +understand. You can follow the detailed roadmap in [this enhancement +proposal](https://github.com/datum-cloud/enhancements/issues/469). + +## Documentation + +- [Architecture Overview](docs/architecture/README.md) - System design and components +- [API Reference](docs/api.md) - API specifications + +## Who is this for? + +- **Platform teams** who need to understand cluster activity across multiple tenants +- **Security teams** investigating incidents or building compliance reports +- **Developers** debugging "who changed what" questions +- **Anyone** who's ever wished Kubernetes audit logs were easier to query + +## Prerequisites + +**For users:** +- Kubernetes 1.34+ cluster +- kubectl configured to access your cluster + +**For developers:** +- Go 1.24.0 or later +- [Task](https://taskfile.dev) for development workflows +- Docker for building container images + +## License + +See [LICENSE](LICENSE) for details. + +--- + +**Questions or feedback?** Open an issue—we're here to help! diff --git a/Taskfile.yaml b/Taskfile.yaml index 1ff7527d..bedb7edc 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -1,670 +1,670 @@ -version: '3' - -vars: - TOOL_DIR: "{{.USER_WORKING_DIR}}/bin" - # Container image configuration - ACTIVITY_IMAGE_NAME: "ghcr.io/datum-cloud/activity" - ACTIVITY_UI_IMAGE_NAME: "ghcr.io/datum-cloud/activity-ui" - ACTIVITY_IMAGE_TAG: "dev" - TEST_INFRA_CLUSTER_NAME: "test-infra" - # Test infra repository configuration - can be overridden with environment variable - TEST_INFRA_REPO_REF: 'v0.6.0' - # ClickHouse configuration for testing - CLICKHOUSE_DATABASE: "audit" - CLICKHOUSE_USERNAME: "default" - CLICKHOUSE_PASSWORD: "" - -includes: - # Must set TASK_X_REMOTE_TASKFILES=1 to use this feature. - # - # See: https://taskfile.dev/experiments/remote-taskfiles - test-infra: - taskfile: https://raw.githubusercontent.com/datum-cloud/test-infra/{{.TEST_INFRA_REPO_REF}}/Taskfile.yml - checksum: a1cf6063def6ee21ba42f8a0818127c92a9a5c313c293387f2294e42480dd3d5 - vars: - REPO_REF: "{{.TEST_INFRA_REPO_REF}}" - - # Database migrations - migrations: - taskfile: ./migrations/Taskfile.yaml - dir: ./migrations - vars: - CLICKHOUSE_DATABASE: "{{.CLICKHOUSE_DATABASE}}" - CLICKHOUSE_USERNAME: "{{.CLICKHOUSE_USERNAME}}" - CLICKHOUSE_PASSWORD: "{{.CLICKHOUSE_PASSWORD}}" - ROOT_DIR: "{{.USER_WORKING_DIR}}" - observability: - taskfile: ./observability/Taskfile.yaml - dir: ./observability - vars: - ROOT_DIR: "{{.USER_WORKING_DIR}}" - - # Performance testing with k6 - load: - taskfile: ./test/load/Taskfile.yaml - dir: ./test/load - vars: - ROOT_DIR: "{{.USER_WORKING_DIR}}" - - # Documentation (diagrams, etc.) - docs: - taskfile: ./docs/Taskfile.yaml - dir: ./docs - -tasks: - default: - desc: List all available tasks - cmds: - - task --list - silent: true - - # Build tasks - build: - desc: Build the activity binary - cmds: - - | - set -e - echo "Building activity..." - mkdir -p {{.TOOL_DIR}} - - # Get git information for version injection - GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") - VERSION="v0.0.0-dev+${GIT_COMMIT:0:7}" - GIT_TREE_STATE="clean" - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then - GIT_TREE_STATE="dirty" - fi - BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "unknown") - - echo "Version: ${VERSION}, Commit: ${GIT_COMMIT:0:7}, Tree: ${GIT_TREE_STATE}" - - go build \ - -ldflags="-X 'go.miloapis.com/activity/internal/version.Version=${VERSION}' \ - -X 'go.miloapis.com/activity/internal/version.GitCommit=${GIT_COMMIT}' \ - -X 'go.miloapis.com/activity/internal/version.GitTreeState=${GIT_TREE_STATE}' \ - -X 'go.miloapis.com/activity/internal/version.BuildDate=${BUILD_DATE}'" \ - -o {{.TOOL_DIR}}/activity ./cmd/activity - echo "✅ Binary built: {{.TOOL_DIR}}/activity" - silent: true - - # Development tasks - dev:build: - desc: Build the Activity server container image for development - silent: true - cmds: - - | - set -e - echo "Building Activity server container image: {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" - - # Get git information for version injection - GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") - VERSION="v0.0.0-dev+${GIT_COMMIT:0:7}" - GIT_TREE_STATE="clean" - if [ -n "$(git status --porcelain 2>/dev/null)" ]; then - GIT_TREE_STATE="dirty" - fi - BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "unknown") - - echo "Version info: ${VERSION}, commit: ${GIT_COMMIT:0:7}, tree: ${GIT_TREE_STATE}" - - # Build using a simple Dockerfile (to be created) - if [ ! -f "Dockerfile" ]; then - echo "⚠️ Warning: Dockerfile not found - skipping container build" - echo "Please create a Dockerfile to enable container builds" - exit 1 - fi - - docker build \ - --build-arg VERSION="${VERSION}" \ - --build-arg GIT_COMMIT="${GIT_COMMIT}" \ - --build-arg GIT_TREE_STATE="${GIT_TREE_STATE}" \ - --build-arg BUILD_DATE="${BUILD_DATE}" \ - -t "{{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" . - echo "Successfully built {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" - - dev:load: - desc: Load the Activity server container image into the kind cluster - silent: true - cmds: - - | - set -e - echo "Loading image {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}} into kind cluster '{{.TEST_INFRA_CLUSTER_NAME}}'..." - kind load docker-image "{{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" --name "{{.TEST_INFRA_CLUSTER_NAME}}" - echo "Successfully loaded image into kind cluster" - - dev:build-ui: - desc: Build the Activity UI container image for development - silent: true - cmds: - - | - set -e - echo "Building Activity UI container image: {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" - - if [ ! -f "ui/Dockerfile" ]; then - echo "⚠️ Warning: ui/Dockerfile not found - skipping UI container build" - exit 1 - fi - - docker build \ - -t "{{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" \ - -f ui/Dockerfile \ - ui/ - echo "Successfully built {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" - - dev:load-ui: - desc: Load the Activity UI container image into the kind cluster - silent: true - cmds: - - | - set -e - echo "Loading image {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}} into kind cluster '{{.TEST_INFRA_CLUSTER_NAME}}'..." - kind load docker-image "{{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" --name "{{.TEST_INFRA_CLUSTER_NAME}}" - echo "Successfully loaded UI image into kind cluster" - - # ============================================================ - # Lightweight Dev Environment (single-replica, minimal resources) - # ============================================================ - dev:setup: - desc: Setup lightweight dev environment (single-replica ClickHouse, minimal resources) - silent: true - cmds: - - task: test-infra:cluster-up - - task: test-infra:install-observability - - task: dev:install-dependencies-minimal - - task: dev:build - - task: dev:load - - task: dev:build-ui - - task: dev:load-ui - - task: dev:deploy - - task: observability:deploy - - dev:install-dependencies-minimal: - desc: Install minimal infrastructure dependencies for dev (no RustFS) - silent: true - cmds: - - | - set -e - echo "📦 Installing minimal infrastructure dependencies for dev..." - echo "" - - # ============================================================ - # Install ClickHouse Operator - # ============================================================ - echo "📦 Installing ClickHouse operator via Flux HelmRelease..." - - echo "Applying Flux HelmRelease for ClickHouse operator..." - task test-infra:kubectl -- apply -k config/dependencies/clickhouse-operator - - echo "Waiting for operator HelmRelease to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/clickhouse-operator -n clickhouse-system --timeout=300s 2>/dev/null || echo "⚠️ HelmRelease not ready yet (may need Flux installed)" - - echo "Waiting for operator deployment to be ready..." - task test-infra:kubectl -- wait --for=condition=available deployment/clickhouse-operator -n clickhouse-system --timeout=120s 2>/dev/null || echo "⚠️ Operator deployment not ready yet" - - echo "✅ ClickHouse operator installed" - echo "" - - # ============================================================ - # Install NATS - # ============================================================ - echo "📦 Installing NATS for event streaming..." - - echo "Applying NATS resources..." - task test-infra:kubectl -- apply -k config/dependencies/nats - - echo "Waiting for NATS namespace to be created..." - task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/nats-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" - - echo "Waiting for NATS HelmRelease to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/nats -n nats-system --timeout=300s 2>/dev/null || echo "⚠️ NATS HelmRelease not ready yet (may need Flux installed)" - - echo "Waiting for NATS pods to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=nats -n nats-system --timeout=120s 2>/dev/null || echo "⚠️ NATS pods not ready yet" - - echo "✅ NATS installed" - echo "" - - # Note: RustFS is NOT installed for dev - we use local disk for storage - # Note: etcd is deployed as part of the dev overlay (not as a dependency) - echo "ℹ️ Skipping RustFS (dev uses local disk for ClickHouse storage)" - echo "" - - echo "✅ Minimal infrastructure dependencies installed!" - echo "" - - dev:deploy: - desc: Deploy Activity server using dev overlay (lightweight, non-HA) - silent: true - cmds: - - | - set -e - echo "🚀 Deploying Activity server (dev overlay - lightweight)..." - - # Check if deployment manifests exist - if [ ! -d "config" ]; then - echo "⚠️ Warning: config directory not found" - exit 1 - fi - - echo "📋 Deploying Activity server (dev overlay)..." - task test-infra:kubectl -- apply -k config/overlays/dev - - echo "⏳ Waiting for etcd to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/etcd -n activity-system --timeout=300s 2>/dev/null || echo "⚠️ etcd HelmRelease not ready yet" - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=etcd -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ etcd pods not ready yet" - - echo "⏳ Waiting for NATS streams to be ready..." - task test-infra:kubectl -- wait --for=condition=ready stream/audit-events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ audit-events stream not ready yet" - task test-infra:kubectl -- wait --for=condition=ready stream/activities -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ activities stream not ready yet" - task test-infra:kubectl -- wait --for=condition=ready stream/events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ events stream not ready yet (needed for Watch API)" - - echo "" - echo "⏳ Waiting for ClickHouse Keeper to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse-keeper.altinity.com/chk=activity-keeper -n activity-system --timeout=180s || echo "⚠️ ClickHouse Keeper pods not ready yet" - - echo "⏳ Waiting for ClickHouse to be ready..." - task test-infra:kubectl -- wait --for=jsonpath='{.status.status}'=Completed clickhouseinstallation -n activity-system activity-clickhouse --timeout=180s 2>/dev/null || echo "⚠️ ClickHouse Installation not completed" - task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system --timeout=180s || echo "⚠️ ClickHouse pods not ready yet" - - echo "⏳ Waiting for ClickHouse migrations to complete..." - task test-infra:kubectl -- wait --for=condition=complete job/clickhouse-migrate -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Migration job not complete yet" - - echo "⏳ Waiting for Activity server to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app=activity-apiserver -n activity-system --timeout=120s || echo "⚠️ API server pods not ready yet" - - echo "⏳ Waiting for Activity APIService to be available..." - task test-infra:kubectl -- wait --for=condition=Available apiservice/v1alpha1.activity.miloapis.com --timeout=120s || echo "⚠️ APIService not available yet" - - echo "⏳ Waiting for Vector aggregator to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/instance=vector-aggregator -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Vector aggregator pods not ready yet" - - echo "" - echo "📋 Installing example ActivityPolicies for basic Kubernetes resources..." - task test-infra:kubectl -- apply -k examples/basic-kubernetes/ - - echo "" - echo "✅ Activity server deployed (dev overlay)!" - echo "" - echo "📊 Check status:" - echo " All resources: task test-infra:kubectl -- get all -n activity-system" - echo " API server pods: task test-infra:kubectl -- get pods -l app=activity-apiserver -n activity-system" - echo " ClickHouse pods: task test-infra:kubectl -- get pods -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system" - echo " ActivityPolicies: task test-infra:kubectl -- get activitypolicies" - echo "" - echo "📋 View logs:" - echo " API server: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" - echo " ClickHouse: task test-infra:kubectl -- logs -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system -f" - echo "" - - dev:redeploy: - desc: Quick rebuild and redeploy for dev environment - deps: - - dev:build - - dev:load - - dev:build-ui - - dev:load-ui - cmds: - - | - set -e - echo "Redeploying Activity server and UI (dev)..." - - # Restart all activity deployments to pick up new image - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-apiserver || echo "⚠️ Deployment not found, run 'task dev:deploy' first" - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-processor || echo "⚠️ activity-processor deployment not found" - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-controller-manager || echo "⚠️ activity-controller-manager deployment not found" - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-ui || echo "⚠️ activity-ui deployment not found" - - # Wait for rollouts to complete - echo "Waiting for rollouts to complete..." - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-apiserver --timeout=120s || true - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-processor --timeout=120s || true - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-controller-manager --timeout=120s || true - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-ui --timeout=120s || true - - echo "✅ Redeployment complete!" - echo "Check logs with: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" - silent: true - - # ============================================================ - # Test Environment (full HA setup with RustFS) - # ============================================================ - test:setup: - desc: Setup full test environment with HA ClickHouse (3 replicas), S3 storage, and all components - silent: true - cmds: - - task: test-infra:cluster-up - - task: test-infra:install-observability - - task: test:install-dependencies - - task: dev:build - - task: dev:load - - task: dev:build-ui - - task: dev:load-ui - - task: test:deploy - - task: observability:deploy - - test:install-dependencies: - desc: Install all infrastructure dependencies for test environment (includes RustFS) - silent: true - cmds: - - | - set -e - echo "📦 Installing infrastructure dependencies..." - echo "" - - # ============================================================ - # Install ClickHouse Operator - # ============================================================ - echo "📦 Installing ClickHouse operator via Flux HelmRelease..." - - echo "Applying Flux HelmRelease for ClickHouse operator..." - task test-infra:kubectl -- apply -k config/dependencies/clickhouse-operator - - echo "Waiting for operator HelmRelease to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/clickhouse-operator -n clickhouse-system --timeout=300s 2>/dev/null || echo "⚠️ HelmRelease not ready yet (may need Flux installed)" - - echo "Waiting for operator deployment to be ready..." - task test-infra:kubectl -- wait --for=condition=available deployment/clickhouse-operator -n clickhouse-system --timeout=120s 2>/dev/null || echo "⚠️ Operator deployment not ready yet" - - echo "✅ ClickHouse operator installed" - echo "" - - # ============================================================ - # Install NATS - # ============================================================ - echo "📦 Installing NATS for event streaming..." - - echo "Applying NATS resources..." - task test-infra:kubectl -- apply -k config/dependencies/nats - - echo "Waiting for NATS namespace to be created..." - task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/nats-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" - - echo "Waiting for NATS HelmRelease to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/nats -n nats-system --timeout=300s 2>/dev/null || echo "⚠️ NATS HelmRelease not ready yet (may need Flux installed)" - - echo "Waiting for NATS pods to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=nats -n nats-system --timeout=120s 2>/dev/null || echo "⚠️ NATS pods not ready yet" - - echo "✅ NATS installed" - echo "" - - # Note: NACK controller is now installed as part of NATS dependencies kustomization above - # Stream configurations will be deployed as part of test:deploy step - - # ============================================================ - # Install RustFS for S3-compatible object storage - # ============================================================ - echo "📦 Installing RustFS for S3-compatible object storage..." - - echo "Applying RustFS resources..." - task test-infra:kubectl -- apply -k config/dependencies/rustfs - - echo "Waiting for rustfs-system namespace to be created..." - task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/rustfs-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" - - echo "Waiting for RustFS HelmRelease to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/rustfs -n rustfs-system --timeout=300s 2>/dev/null || echo "⚠️ RustFS HelmRelease not ready yet (may need Flux installed)" - - echo "Waiting for RustFS deployment to be ready..." - task test-infra:kubectl -- wait --for=condition=available deployment/rustfs -n rustfs-system --timeout=180s 2>/dev/null || echo "⚠️ RustFS deployment not ready yet" - - echo "✅ RustFS storage installed" - echo "" - - # ============================================================ - # Summary - # ============================================================ - echo "✅ All infrastructure dependencies installed successfully!" - echo "" - echo "📊 Check status:" - echo " ClickHouse operator: task test-infra:kubectl -- get pods -n clickhouse-system" - echo " NATS: task test-infra:kubectl -- get pods -n nats-system" - echo " NACK controller: task test-infra:kubectl -- get pods -n nats-system -l app.kubernetes.io/name=nack" - echo " RustFS: task test-infra:kubectl -- get pods -n rustfs-system" - echo "" - echo "📋 HelmRelease status:" - echo " task test-infra:kubectl -- get helmrelease -n clickhouse-system" - echo " task test-infra:kubectl -- get helmrelease -n nats-system" - echo " task test-infra:kubectl -- get helmrelease -n rustfs-system" - echo "" - echo "Note: JetStream stream configurations and S3 bucket will be deployed with the Activity server." - echo "" - - test:deploy: - desc: Deploy Activity server using test-infra overlay (full HA with 3 replicas) - silent: true - cmds: - - | - set -e - echo "🚀 Deploying Activity server (test-infra overlay - full HA)..." - - # Check if deployment manifests exist - if [ ! -d "config" ]; then - echo "⚠️ Warning: config directory not found" - exit 1 - fi - - echo "📋 Deploying Activity server and components (test-infra overlay)..." - task test-infra:kubectl -- apply -k config/overlays/test-infra - - echo "⏳ Waiting for etcd to be ready..." - task test-infra:kubectl -- wait --for=condition=ready helmrelease/etcd -n activity-system --timeout=300s 2>/dev/null || echo "⚠️ etcd HelmRelease not ready yet" - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=etcd -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ etcd pods not ready yet" - - echo "⏳ Waiting for NATS streams to be ready..." - task test-infra:kubectl -- wait --for=condition=ready stream/audit-events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ audit-events stream not ready yet" - task test-infra:kubectl -- wait --for=condition=ready stream/activities -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ activities stream not ready yet" - task test-infra:kubectl -- wait --for=condition=ready stream/events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ events stream not ready yet (needed for Watch API)" - - echo "" - echo "⏳ Waiting for RustFS bucket initialization..." - task test-infra:kubectl -- wait --for=condition=complete job/rustfs-bucket-init -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Bucket initialization not complete yet" - - echo "⏳ Waiting for ClickHouse Keeper to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse-keeper.altinity.com/chk=activity-keeper -n activity-system --timeout=180s || echo "⚠️ ClickHouse Keeper pods not ready yet" - - echo "⏳ Waiting for ClickHouse to be ready..." - task test-infra:kubectl -- wait --for=jsonpath='{.status.status}'=Completed clickhouseinstallation -n activity-system activity-clickhouse --timeout=180s 2>/dev/null || echo "⚠️ ClickHouse Installation not completed" - task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system --timeout=180s || echo "⚠️ ClickHouse pods not ready yet" - - echo "⏳ Waiting for ClickHouse migrations to complete..." - task test-infra:kubectl -- wait --for=condition=complete job/clickhouse-migrate -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Migration job not complete yet" - - echo "⏳ Waiting for Activity server to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app=activity-apiserver -n activity-system --timeout=120s || echo "⚠️ API server pods not ready yet" - - echo "⏳ Waiting for Vector aggregator to be ready..." - task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/instance=vector-aggregator -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Vector aggregator pods not ready yet" - - echo "" - echo "⏳ Waiting for Grafana ClickHouse datasource to be synced..." - sleep 5 - task test-infra:kubectl -- wait --for=condition=DatasourceSynchronized grafanadatasource/clickhouse-datasource -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ Datasource not synced yet (may need to restart Grafana pod for plugin to load)" - - echo "" - echo "📋 Installing example ActivityPolicies for basic Kubernetes resources..." - task test-infra:kubectl -- apply -k examples/basic-kubernetes/ - - echo "" - echo "✅ Activity server deployed (test-infra overlay - full HA)!" - echo "" - echo "📊 Check status:" - echo " All resources: task test-infra:kubectl -- get all -n activity-system" - echo " API server pods: task test-infra:kubectl -- get pods -l app=activity-apiserver -n activity-system" - echo " ClickHouse pods: task test-infra:kubectl -- get pods -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system" - echo " Vector pods: task test-infra:kubectl -- get pods -l app.kubernetes.io/instance=vector-aggregator -n activity-system" - echo " NATS pods: task test-infra:kubectl -- get pods -n nats-system" - echo " NATS streams: task test-infra:kubectl -- get streams -n activity-system" - echo " S3 bucket: task test-infra:kubectl -- get objectbucketclaim -n activity-system" - echo " API service: kubectl get apiservice v1alpha1.activity.miloapis.com" - echo " Grafana datasrc: task test-infra:kubectl -- get grafanadatasource clickhouse-datasource -n activity-system" - echo " ActivityPolicies: task test-infra:kubectl -- get activitypolicies" - echo "" - echo "📋 View logs:" - echo " API server: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" - echo " ClickHouse: task test-infra:kubectl -- logs -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system -f" - echo " Vector: task test-infra:kubectl -- logs -l app.kubernetes.io/instance=vector-aggregator -n activity-system -f" - echo " NATS: task test-infra:kubectl -- logs -l app.kubernetes.io/name=nats -n nats-system -f" - echo "" - echo "📊 Observability:" - echo " Access Grafana: task test-infra:kubectl -- port-forward -n telemetry-system svc/grafana-service 3000:3000" - echo " Grafana URL: http://localhost:3000 (admin / datum123)" - echo " Verify datasource: task test-infra:kubectl -- get grafanadatasource -n activity-system" - echo "" - - test:redeploy: - desc: Quick rebuild and redeploy for test environment - deps: - - dev:build - - dev:load - cmds: - - | - set -e - echo "Redeploying Activity server (test)..." - - # Restart all activity deployments to pick up new image - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-apiserver || echo "⚠️ Deployment not found, run 'task test:deploy' first" - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-processor || echo "⚠️ activity-processor deployment not found" - task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-controller-manager || echo "⚠️ activity-controller-manager deployment not found" - - # Wait for rollouts to complete - echo "Waiting for rollouts to complete..." - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-apiserver --timeout=120s || true - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-processor --timeout=120s || true - task test-infra:kubectl -- rollout status -n activity-system deployment/activity-controller-manager --timeout=120s || true - - echo "✅ Redeployment complete!" - echo "Check logs with: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" - silent: true - - # ============================================================ - # Telepresence for remote debugging - # ============================================================ - # Telepresence tasks are now provided by test-infra: - # task test-infra:telepresence:install - Install CLI - # task test-infra:telepresence:connect - Connect to cluster - # task test-infra:telepresence:intercept - Intercept service (SERVICE=name NAMESPACE=ns PORT=port) - # task test-infra:telepresence:status - Show status - # task test-infra:telepresence:quit - Disconnect - - # ============================================================ - # UI Development - # ============================================================ - ui:dev: - desc: Run the Activity UI example app locally with hot-reload - dir: ui/example - cmds: - - | - set -e - echo "🚀 Starting Activity UI dev server..." - echo "" - echo "The example app imports directly from ui/src/ for hot-reload." - echo "Changes to ui/src/ will automatically update in the browser." - echo "" - npm run dev - - ui:build: - desc: Build the Activity UI component library - dir: ui - cmds: - - | - set -e - echo "📦 Building Activity UI component library..." - npm run build - echo "✅ Library built to ui/dist/" - - ui:type-check: - desc: Type-check the Activity UI component library - dir: ui - cmds: - - npm run type-check - - ui:lint: - desc: Lint the Activity UI component library - dir: ui - cmds: - - npm run lint - - # OpenAPI code generation - generate:openapi: - desc: Generate Kubernetes OpenAPI definitions - cmds: - - | - set -e - echo "🔄 Generating Kubernetes OpenAPI definitions..." - ./hack/update-codegen.sh - echo "✅ OpenAPI generation complete" - silent: true - - # RBAC generation from kubebuilder annotations - generate:rbac: - desc: Generate RBAC manifests from kubebuilder annotations - cmds: - - | - set -e - echo "🔄 Generating RBAC manifests from kubebuilder annotations..." - ./hack/generate-rbac.sh - echo "✅ RBAC generation complete" - silent: true - - # API documentation generation - generate:docs: - desc: Generate API reference documentation - cmds: - - | - set -e - echo "🔄 Generating API reference documentation..." - chmod +x ./hack/generate-api-docs.sh - ./hack/generate-api-docs.sh - echo "✅ API documentation generation complete" - silent: true - - # Architecture diagram tasks - diagrams: - desc: Generate architecture diagrams from PlantUML - cmds: - - task: docs:diagrams - silent: true - - # Unified code generation task - composes all generate:* subtasks - generate: - desc: Run all code generation tasks (OpenAPI, RBAC, migrations ConfigMap, API docs, diagrams, k6 tests, etc.) - deps: - - generate:openapi - - generate:rbac - - migrations:generate - - load:generate - - observability:build-mixin - - generate:docs - - docs:generate - cmds: - - | - echo "" - echo "🎉 All code generation complete!" - echo "" - echo "Generated files:" - echo " - pkg/generated/openapi/zz_generated.openapi.go" - echo " - config/base/generated/controller-manager-rbac.yaml" - echo " - config/components/clickhouse-migrations/configmap.yaml" - echo " - config/components/k6-performance-tests/generated/query-load-test.js" - echo " - docs/api.md" - echo "" - echo "Next steps:" - echo " - Review generated files" - echo " - Commit: git add pkg/ config/ docs/ && git commit -m 'chore: regenerate code and docs'" - silent: true - - # ============================================================ - # E2E Testing with Chainsaw - # ============================================================ - e2e: - desc: Run chainsaw e2e tests - cmds: - - chainsaw test test/e2e/ --config test/e2e/chainsaw-test.yaml - silent: false - - e2e:named-rules: - desc: Run named rules e2e tests only - cmds: - - chainsaw test test/e2e/activitypolicy/named-rules/ - silent: false +version: '3' + +vars: + TOOL_DIR: "{{.USER_WORKING_DIR}}/bin" + # Container image configuration + ACTIVITY_IMAGE_NAME: "ghcr.io/datum-cloud/activity" + ACTIVITY_UI_IMAGE_NAME: "ghcr.io/datum-cloud/activity-ui" + ACTIVITY_IMAGE_TAG: "dev" + TEST_INFRA_CLUSTER_NAME: "test-infra" + # Test infra repository configuration - can be overridden with environment variable + TEST_INFRA_REPO_REF: 'v0.6.0' + # ClickHouse configuration for testing + CLICKHOUSE_DATABASE: "audit" + CLICKHOUSE_USERNAME: "default" + CLICKHOUSE_PASSWORD: "" + +includes: + # Must set TASK_X_REMOTE_TASKFILES=1 to use this feature. + # + # See: https://taskfile.dev/experiments/remote-taskfiles + test-infra: + taskfile: https://raw.githubusercontent.com/datum-cloud/test-infra/{{.TEST_INFRA_REPO_REF}}/Taskfile.yml + checksum: a1cf6063def6ee21ba42f8a0818127c92a9a5c313c293387f2294e42480dd3d5 + vars: + REPO_REF: "{{.TEST_INFRA_REPO_REF}}" + + # Database migrations + migrations: + taskfile: ./migrations/Taskfile.yaml + dir: ./migrations + vars: + CLICKHOUSE_DATABASE: "{{.CLICKHOUSE_DATABASE}}" + CLICKHOUSE_USERNAME: "{{.CLICKHOUSE_USERNAME}}" + CLICKHOUSE_PASSWORD: "{{.CLICKHOUSE_PASSWORD}}" + ROOT_DIR: "{{.USER_WORKING_DIR}}" + observability: + taskfile: ./observability/Taskfile.yaml + dir: ./observability + vars: + ROOT_DIR: "{{.USER_WORKING_DIR}}" + + # Performance testing with k6 + load: + taskfile: ./test/load/Taskfile.yaml + dir: ./test/load + vars: + ROOT_DIR: "{{.USER_WORKING_DIR}}" + + # Documentation (diagrams, etc.) + docs: + taskfile: ./docs/Taskfile.yaml + dir: ./docs + +tasks: + default: + desc: List all available tasks + cmds: + - task --list + silent: true + + # Build tasks + build: + desc: Build the activity binary + cmds: + - | + set -e + echo "Building activity..." + mkdir -p {{.TOOL_DIR}} + + # Get git information for version injection + GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") + VERSION="v0.0.0-dev+${GIT_COMMIT:0:7}" + GIT_TREE_STATE="clean" + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + GIT_TREE_STATE="dirty" + fi + BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "unknown") + + echo "Version: ${VERSION}, Commit: ${GIT_COMMIT:0:7}, Tree: ${GIT_TREE_STATE}" + + go build \ + -ldflags="-X 'go.miloapis.com/activity/internal/version.Version=${VERSION}' \ + -X 'go.miloapis.com/activity/internal/version.GitCommit=${GIT_COMMIT}' \ + -X 'go.miloapis.com/activity/internal/version.GitTreeState=${GIT_TREE_STATE}' \ + -X 'go.miloapis.com/activity/internal/version.BuildDate=${BUILD_DATE}'" \ + -o {{.TOOL_DIR}}/activity ./cmd/activity + echo "✅ Binary built: {{.TOOL_DIR}}/activity" + silent: true + + # Development tasks + dev:build: + desc: Build the Activity server container image for development + silent: true + cmds: + - | + set -e + echo "Building Activity server container image: {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" + + # Get git information for version injection + GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "unknown") + VERSION="v0.0.0-dev+${GIT_COMMIT:0:7}" + GIT_TREE_STATE="clean" + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + GIT_TREE_STATE="dirty" + fi + BUILD_DATE=$(date -u '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "unknown") + + echo "Version info: ${VERSION}, commit: ${GIT_COMMIT:0:7}, tree: ${GIT_TREE_STATE}" + + # Build using a simple Dockerfile (to be created) + if [ ! -f "Dockerfile" ]; then + echo "⚠️ Warning: Dockerfile not found - skipping container build" + echo "Please create a Dockerfile to enable container builds" + exit 1 + fi + + docker build \ + --build-arg VERSION="${VERSION}" \ + --build-arg GIT_COMMIT="${GIT_COMMIT}" \ + --build-arg GIT_TREE_STATE="${GIT_TREE_STATE}" \ + --build-arg BUILD_DATE="${BUILD_DATE}" \ + -t "{{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" . + echo "Successfully built {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" + + dev:load: + desc: Load the Activity server container image into the kind cluster + silent: true + cmds: + - | + set -e + echo "Loading image {{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}} into kind cluster '{{.TEST_INFRA_CLUSTER_NAME}}'..." + kind load docker-image "{{.ACTIVITY_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" --name "{{.TEST_INFRA_CLUSTER_NAME}}" + echo "Successfully loaded image into kind cluster" + + dev:build-ui: + desc: Build the Activity UI container image for development + silent: true + cmds: + - | + set -e + echo "Building Activity UI container image: {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" + + if [ ! -f "ui/Dockerfile" ]; then + echo "⚠️ Warning: ui/Dockerfile not found - skipping UI container build" + exit 1 + fi + + docker build \ + -t "{{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" \ + -f ui/Dockerfile \ + ui/ + echo "Successfully built {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" + + dev:load-ui: + desc: Load the Activity UI container image into the kind cluster + silent: true + cmds: + - | + set -e + echo "Loading image {{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}} into kind cluster '{{.TEST_INFRA_CLUSTER_NAME}}'..." + kind load docker-image "{{.ACTIVITY_UI_IMAGE_NAME}}:{{.ACTIVITY_IMAGE_TAG}}" --name "{{.TEST_INFRA_CLUSTER_NAME}}" + echo "Successfully loaded UI image into kind cluster" + + # ============================================================ + # Lightweight Dev Environment (single-replica, minimal resources) + # ============================================================ + dev:setup: + desc: Setup lightweight dev environment (single-replica ClickHouse, minimal resources) + silent: true + cmds: + - task: test-infra:cluster-up + - task: test-infra:install-observability + - task: dev:install-dependencies-minimal + - task: dev:build + - task: dev:load + - task: dev:build-ui + - task: dev:load-ui + - task: dev:deploy + - task: observability:deploy + + dev:install-dependencies-minimal: + desc: Install minimal infrastructure dependencies for dev (no RustFS) + silent: true + cmds: + - | + set -e + echo "📦 Installing minimal infrastructure dependencies for dev..." + echo "" + + # ============================================================ + # Install ClickHouse Operator + # ============================================================ + echo "📦 Installing ClickHouse operator via Flux HelmRelease..." + + echo "Applying Flux HelmRelease for ClickHouse operator..." + task test-infra:kubectl -- apply -k config/dependencies/clickhouse-operator + + echo "Waiting for operator HelmRelease to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/clickhouse-operator -n clickhouse-system --timeout=300s 2>/dev/null || echo "⚠️ HelmRelease not ready yet (may need Flux installed)" + + echo "Waiting for operator deployment to be ready..." + task test-infra:kubectl -- wait --for=condition=available deployment/clickhouse-operator -n clickhouse-system --timeout=120s 2>/dev/null || echo "⚠️ Operator deployment not ready yet" + + echo "✅ ClickHouse operator installed" + echo "" + + # ============================================================ + # Install NATS + # ============================================================ + echo "📦 Installing NATS for event streaming..." + + echo "Applying NATS resources..." + task test-infra:kubectl -- apply -k config/dependencies/nats + + echo "Waiting for NATS namespace to be created..." + task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/nats-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" + + echo "Waiting for NATS HelmRelease to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/nats -n nats-system --timeout=300s 2>/dev/null || echo "⚠️ NATS HelmRelease not ready yet (may need Flux installed)" + + echo "Waiting for NATS pods to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=nats -n nats-system --timeout=120s 2>/dev/null || echo "⚠️ NATS pods not ready yet" + + echo "✅ NATS installed" + echo "" + + # Note: RustFS is NOT installed for dev - we use local disk for storage + # Note: etcd is deployed as part of the dev overlay (not as a dependency) + echo "ℹ️ Skipping RustFS (dev uses local disk for ClickHouse storage)" + echo "" + + echo "✅ Minimal infrastructure dependencies installed!" + echo "" + + dev:deploy: + desc: Deploy Activity server using dev overlay (lightweight, non-HA) + silent: true + cmds: + - | + set -e + echo "🚀 Deploying Activity server (dev overlay - lightweight)..." + + # Check if deployment manifests exist + if [ ! -d "config" ]; then + echo "⚠️ Warning: config directory not found" + exit 1 + fi + + echo "📋 Deploying Activity server (dev overlay)..." + task test-infra:kubectl -- apply -k config/overlays/dev + + echo "⏳ Waiting for etcd to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/etcd -n activity-system --timeout=300s 2>/dev/null || echo "⚠️ etcd HelmRelease not ready yet" + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=etcd -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ etcd pods not ready yet" + + echo "⏳ Waiting for NATS streams to be ready..." + task test-infra:kubectl -- wait --for=condition=ready stream/audit-events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ audit-events stream not ready yet" + task test-infra:kubectl -- wait --for=condition=ready stream/activities -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ activities stream not ready yet" + task test-infra:kubectl -- wait --for=condition=ready stream/events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ events stream not ready yet (needed for Watch API)" + + echo "" + echo "⏳ Waiting for ClickHouse Keeper to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse-keeper.altinity.com/chk=activity-keeper -n activity-system --timeout=180s || echo "⚠️ ClickHouse Keeper pods not ready yet" + + echo "⏳ Waiting for ClickHouse to be ready..." + task test-infra:kubectl -- wait --for=jsonpath='{.status.status}'=Completed clickhouseinstallation -n activity-system activity-clickhouse --timeout=180s 2>/dev/null || echo "⚠️ ClickHouse Installation not completed" + task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system --timeout=180s || echo "⚠️ ClickHouse pods not ready yet" + + echo "⏳ Waiting for ClickHouse migrations to complete..." + task test-infra:kubectl -- wait --for=condition=complete job/clickhouse-migrate -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Migration job not complete yet" + + echo "⏳ Waiting for Activity server to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app=activity-apiserver -n activity-system --timeout=120s || echo "⚠️ API server pods not ready yet" + + echo "⏳ Waiting for Activity APIService to be available..." + task test-infra:kubectl -- wait --for=condition=Available apiservice/v1alpha1.activity.miloapis.com --timeout=120s || echo "⚠️ APIService not available yet" + + echo "⏳ Waiting for Vector aggregator to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/instance=vector-aggregator -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Vector aggregator pods not ready yet" + + echo "" + echo "📋 Installing example ActivityPolicies for basic Kubernetes resources..." + task test-infra:kubectl -- apply -k examples/basic-kubernetes/ + + echo "" + echo "✅ Activity server deployed (dev overlay)!" + echo "" + echo "📊 Check status:" + echo " All resources: task test-infra:kubectl -- get all -n activity-system" + echo " API server pods: task test-infra:kubectl -- get pods -l app=activity-apiserver -n activity-system" + echo " ClickHouse pods: task test-infra:kubectl -- get pods -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system" + echo " ActivityPolicies: task test-infra:kubectl -- get activitypolicies" + echo "" + echo "📋 View logs:" + echo " API server: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" + echo " ClickHouse: task test-infra:kubectl -- logs -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system -f" + echo "" + + dev:redeploy: + desc: Quick rebuild and redeploy for dev environment + deps: + - dev:build + - dev:load + - dev:build-ui + - dev:load-ui + cmds: + - | + set -e + echo "Redeploying Activity server and UI (dev)..." + + # Restart all activity deployments to pick up new image + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-apiserver || echo "⚠️ Deployment not found, run 'task dev:deploy' first" + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-processor || echo "⚠️ activity-processor deployment not found" + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-controller-manager || echo "⚠️ activity-controller-manager deployment not found" + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-ui || echo "⚠️ activity-ui deployment not found" + + # Wait for rollouts to complete + echo "Waiting for rollouts to complete..." + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-apiserver --timeout=120s || true + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-processor --timeout=120s || true + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-controller-manager --timeout=120s || true + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-ui --timeout=120s || true + + echo "✅ Redeployment complete!" + echo "Check logs with: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" + silent: true + + # ============================================================ + # Test Environment (full HA setup with RustFS) + # ============================================================ + test:setup: + desc: Setup full test environment with HA ClickHouse (3 replicas), S3 storage, and all components + silent: true + cmds: + - task: test-infra:cluster-up + - task: test-infra:install-observability + - task: test:install-dependencies + - task: dev:build + - task: dev:load + - task: dev:build-ui + - task: dev:load-ui + - task: test:deploy + - task: observability:deploy + + test:install-dependencies: + desc: Install all infrastructure dependencies for test environment (includes RustFS) + silent: true + cmds: + - | + set -e + echo "📦 Installing infrastructure dependencies..." + echo "" + + # ============================================================ + # Install ClickHouse Operator + # ============================================================ + echo "📦 Installing ClickHouse operator via Flux HelmRelease..." + + echo "Applying Flux HelmRelease for ClickHouse operator..." + task test-infra:kubectl -- apply -k config/dependencies/clickhouse-operator + + echo "Waiting for operator HelmRelease to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/clickhouse-operator -n clickhouse-system --timeout=300s 2>/dev/null || echo "⚠️ HelmRelease not ready yet (may need Flux installed)" + + echo "Waiting for operator deployment to be ready..." + task test-infra:kubectl -- wait --for=condition=available deployment/clickhouse-operator -n clickhouse-system --timeout=120s 2>/dev/null || echo "⚠️ Operator deployment not ready yet" + + echo "✅ ClickHouse operator installed" + echo "" + + # ============================================================ + # Install NATS + # ============================================================ + echo "📦 Installing NATS for event streaming..." + + echo "Applying NATS resources..." + task test-infra:kubectl -- apply -k config/dependencies/nats + + echo "Waiting for NATS namespace to be created..." + task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/nats-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" + + echo "Waiting for NATS HelmRelease to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/nats -n nats-system --timeout=300s 2>/dev/null || echo "⚠️ NATS HelmRelease not ready yet (may need Flux installed)" + + echo "Waiting for NATS pods to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=nats -n nats-system --timeout=120s 2>/dev/null || echo "⚠️ NATS pods not ready yet" + + echo "✅ NATS installed" + echo "" + + # Note: NACK controller is now installed as part of NATS dependencies kustomization above + # Stream configurations will be deployed as part of test:deploy step + + # ============================================================ + # Install RustFS for S3-compatible object storage + # ============================================================ + echo "📦 Installing RustFS for S3-compatible object storage..." + + echo "Applying RustFS resources..." + task test-infra:kubectl -- apply -k config/dependencies/rustfs + + echo "Waiting for rustfs-system namespace to be created..." + task test-infra:kubectl -- wait --for=jsonpath='{.status.phase}'=Active namespace/rustfs-system --timeout=30s 2>/dev/null || echo "⚠️ Namespace not ready yet" + + echo "Waiting for RustFS HelmRelease to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/rustfs -n rustfs-system --timeout=300s 2>/dev/null || echo "⚠️ RustFS HelmRelease not ready yet (may need Flux installed)" + + echo "Waiting for RustFS deployment to be ready..." + task test-infra:kubectl -- wait --for=condition=available deployment/rustfs -n rustfs-system --timeout=180s 2>/dev/null || echo "⚠️ RustFS deployment not ready yet" + + echo "✅ RustFS storage installed" + echo "" + + # ============================================================ + # Summary + # ============================================================ + echo "✅ All infrastructure dependencies installed successfully!" + echo "" + echo "📊 Check status:" + echo " ClickHouse operator: task test-infra:kubectl -- get pods -n clickhouse-system" + echo " NATS: task test-infra:kubectl -- get pods -n nats-system" + echo " NACK controller: task test-infra:kubectl -- get pods -n nats-system -l app.kubernetes.io/name=nack" + echo " RustFS: task test-infra:kubectl -- get pods -n rustfs-system" + echo "" + echo "📋 HelmRelease status:" + echo " task test-infra:kubectl -- get helmrelease -n clickhouse-system" + echo " task test-infra:kubectl -- get helmrelease -n nats-system" + echo " task test-infra:kubectl -- get helmrelease -n rustfs-system" + echo "" + echo "Note: JetStream stream configurations and S3 bucket will be deployed with the Activity server." + echo "" + + test:deploy: + desc: Deploy Activity server using test-infra overlay (full HA with 3 replicas) + silent: true + cmds: + - | + set -e + echo "🚀 Deploying Activity server (test-infra overlay - full HA)..." + + # Check if deployment manifests exist + if [ ! -d "config" ]; then + echo "⚠️ Warning: config directory not found" + exit 1 + fi + + echo "📋 Deploying Activity server and components (test-infra overlay)..." + task test-infra:kubectl -- apply -k config/overlays/test-infra + + echo "⏳ Waiting for etcd to be ready..." + task test-infra:kubectl -- wait --for=condition=ready helmrelease/etcd -n activity-system --timeout=300s 2>/dev/null || echo "⚠️ etcd HelmRelease not ready yet" + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/name=etcd -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ etcd pods not ready yet" + + echo "⏳ Waiting for NATS streams to be ready..." + task test-infra:kubectl -- wait --for=condition=ready stream/audit-events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ audit-events stream not ready yet" + task test-infra:kubectl -- wait --for=condition=ready stream/activities -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ activities stream not ready yet" + task test-infra:kubectl -- wait --for=condition=ready stream/events -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ events stream not ready yet (needed for Watch API)" + + echo "" + echo "⏳ Waiting for RustFS bucket initialization..." + task test-infra:kubectl -- wait --for=condition=complete job/rustfs-bucket-init -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Bucket initialization not complete yet" + + echo "⏳ Waiting for ClickHouse Keeper to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse-keeper.altinity.com/chk=activity-keeper -n activity-system --timeout=180s || echo "⚠️ ClickHouse Keeper pods not ready yet" + + echo "⏳ Waiting for ClickHouse to be ready..." + task test-infra:kubectl -- wait --for=jsonpath='{.status.status}'=Completed clickhouseinstallation -n activity-system activity-clickhouse --timeout=180s 2>/dev/null || echo "⚠️ ClickHouse Installation not completed" + task test-infra:kubectl -- wait --for=condition=ready pod -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system --timeout=180s || echo "⚠️ ClickHouse pods not ready yet" + + echo "⏳ Waiting for ClickHouse migrations to complete..." + task test-infra:kubectl -- wait --for=condition=complete job/clickhouse-migrate -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Migration job not complete yet" + + echo "⏳ Waiting for Activity server to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app=activity-apiserver -n activity-system --timeout=120s || echo "⚠️ API server pods not ready yet" + + echo "⏳ Waiting for Vector aggregator to be ready..." + task test-infra:kubectl -- wait --for=condition=ready pod -l app.kubernetes.io/instance=vector-aggregator -n activity-system --timeout=120s 2>/dev/null || echo "⚠️ Vector aggregator pods not ready yet" + + echo "" + echo "⏳ Waiting for Grafana ClickHouse datasource to be synced..." + sleep 5 + task test-infra:kubectl -- wait --for=condition=DatasourceSynchronized grafanadatasource/clickhouse-datasource -n activity-system --timeout=60s 2>/dev/null || echo "⚠️ Datasource not synced yet (may need to restart Grafana pod for plugin to load)" + + echo "" + echo "📋 Installing example ActivityPolicies for basic Kubernetes resources..." + task test-infra:kubectl -- apply -k examples/basic-kubernetes/ + + echo "" + echo "✅ Activity server deployed (test-infra overlay - full HA)!" + echo "" + echo "📊 Check status:" + echo " All resources: task test-infra:kubectl -- get all -n activity-system" + echo " API server pods: task test-infra:kubectl -- get pods -l app=activity-apiserver -n activity-system" + echo " ClickHouse pods: task test-infra:kubectl -- get pods -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system" + echo " Vector pods: task test-infra:kubectl -- get pods -l app.kubernetes.io/instance=vector-aggregator -n activity-system" + echo " NATS pods: task test-infra:kubectl -- get pods -n nats-system" + echo " NATS streams: task test-infra:kubectl -- get streams -n activity-system" + echo " S3 bucket: task test-infra:kubectl -- get objectbucketclaim -n activity-system" + echo " API service: kubectl get apiservice v1alpha1.activity.miloapis.com" + echo " Grafana datasrc: task test-infra:kubectl -- get grafanadatasource clickhouse-datasource -n activity-system" + echo " ActivityPolicies: task test-infra:kubectl -- get activitypolicies" + echo "" + echo "📋 View logs:" + echo " API server: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" + echo " ClickHouse: task test-infra:kubectl -- logs -l clickhouse.altinity.com/chi=activity-clickhouse -n activity-system -f" + echo " Vector: task test-infra:kubectl -- logs -l app.kubernetes.io/instance=vector-aggregator -n activity-system -f" + echo " NATS: task test-infra:kubectl -- logs -l app.kubernetes.io/name=nats -n nats-system -f" + echo "" + echo "📊 Observability:" + echo " Access Grafana: task test-infra:kubectl -- port-forward -n telemetry-system svc/grafana-service 3000:3000" + echo " Grafana URL: http://localhost:3000 (admin / datum123)" + echo " Verify datasource: task test-infra:kubectl -- get grafanadatasource -n activity-system" + echo "" + + test:redeploy: + desc: Quick rebuild and redeploy for test environment + deps: + - dev:build + - dev:load + cmds: + - | + set -e + echo "Redeploying Activity server (test)..." + + # Restart all activity deployments to pick up new image + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-apiserver || echo "⚠️ Deployment not found, run 'task test:deploy' first" + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-processor || echo "⚠️ activity-processor deployment not found" + task test-infra:kubectl -- rollout restart -n activity-system deployment/activity-controller-manager || echo "⚠️ activity-controller-manager deployment not found" + + # Wait for rollouts to complete + echo "Waiting for rollouts to complete..." + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-apiserver --timeout=120s || true + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-processor --timeout=120s || true + task test-infra:kubectl -- rollout status -n activity-system deployment/activity-controller-manager --timeout=120s || true + + echo "✅ Redeployment complete!" + echo "Check logs with: task test-infra:kubectl -- logs -l app=activity-apiserver -n activity-system -f" + silent: true + + # ============================================================ + # Telepresence for remote debugging + # ============================================================ + # Telepresence tasks are now provided by test-infra: + # task test-infra:telepresence:install - Install CLI + # task test-infra:telepresence:connect - Connect to cluster + # task test-infra:telepresence:intercept - Intercept service (SERVICE=name NAMESPACE=ns PORT=port) + # task test-infra:telepresence:status - Show status + # task test-infra:telepresence:quit - Disconnect + + # ============================================================ + # UI Development + # ============================================================ + ui:dev: + desc: Run the Activity UI example app locally with hot-reload + dir: ui/example + cmds: + - | + set -e + echo "🚀 Starting Activity UI dev server..." + echo "" + echo "The example app imports directly from ui/src/ for hot-reload." + echo "Changes to ui/src/ will automatically update in the browser." + echo "" + npm run dev + + ui:build: + desc: Build the Activity UI component library + dir: ui + cmds: + - | + set -e + echo "📦 Building Activity UI component library..." + npm run build + echo "✅ Library built to ui/dist/" + + ui:type-check: + desc: Type-check the Activity UI component library + dir: ui + cmds: + - npm run type-check + + ui:lint: + desc: Lint the Activity UI component library + dir: ui + cmds: + - npm run lint + + # OpenAPI code generation + generate:openapi: + desc: Generate Kubernetes OpenAPI definitions + cmds: + - | + set -e + echo "🔄 Generating Kubernetes OpenAPI definitions..." + ./hack/update-codegen.sh + echo "✅ OpenAPI generation complete" + silent: true + + # RBAC generation from kubebuilder annotations + generate:rbac: + desc: Generate RBAC manifests from kubebuilder annotations + cmds: + - | + set -e + echo "🔄 Generating RBAC manifests from kubebuilder annotations..." + ./hack/generate-rbac.sh + echo "✅ RBAC generation complete" + silent: true + + # API documentation generation + generate:docs: + desc: Generate API reference documentation + cmds: + - | + set -e + echo "🔄 Generating API reference documentation..." + chmod +x ./hack/generate-api-docs.sh + ./hack/generate-api-docs.sh + echo "✅ API documentation generation complete" + silent: true + + # Architecture diagram tasks + diagrams: + desc: Generate architecture diagrams from PlantUML + cmds: + - task: docs:diagrams + silent: true + + # Unified code generation task - composes all generate:* subtasks + generate: + desc: Run all code generation tasks (OpenAPI, RBAC, migrations ConfigMap, API docs, diagrams, k6 tests, etc.) + deps: + - generate:openapi + - generate:rbac + - migrations:generate + - load:generate + - observability:build-mixin + - generate:docs + - docs:generate + cmds: + - | + echo "" + echo "🎉 All code generation complete!" + echo "" + echo "Generated files:" + echo " - pkg/generated/openapi/zz_generated.openapi.go" + echo " - config/base/generated/controller-manager-rbac.yaml" + echo " - config/components/clickhouse-migrations/configmap.yaml" + echo " - config/components/k6-performance-tests/generated/query-load-test.js" + echo " - docs/api.md" + echo "" + echo "Next steps:" + echo " - Review generated files" + echo " - Commit: git add pkg/ config/ docs/ && git commit -m 'chore: regenerate code and docs'" + silent: true + + # ============================================================ + # E2E Testing with Chainsaw + # ============================================================ + e2e: + desc: Run chainsaw e2e tests + cmds: + - chainsaw test test/e2e/ --config test/e2e/chainsaw-test.yaml + silent: false + + e2e:named-rules: + desc: Run named rules e2e tests only + cmds: + - chainsaw test test/e2e/activitypolicy/named-rules/ + silent: false diff --git a/cmd/activity/controller_manager.go b/cmd/activity/controller_manager.go index e2faba02..a3938cfb 100644 --- a/cmd/activity/controller_manager.go +++ b/cmd/activity/controller_manager.go @@ -1,316 +1,316 @@ -package main - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "os" - "strings" - - "github.com/nats-io/nats.go" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - corev1 "k8s.io/api/core/v1" - utilfeature "k8s.io/apiserver/pkg/util/feature" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - logsapi "k8s.io/component-base/logs/api/v1" - "k8s.io/klog/v2" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "go.miloapis.com/activity/internal/controller" -) - -// ControllerManagerOptions contains configuration for the controller manager. -type ControllerManagerOptions struct { - Kubeconfig string - JobKubeconfig string - MasterURL string - Workers int - MetricsAddr string - HealthProbeAddr string - - // NATS configuration (required) - NATSURL string - NATSTLSEnabled bool - NATSTLSCertFile string - NATSTLSKeyFile string - NATSTLSCAFile string - - // ReindexJob configuration - ReindexJobNamespace string - ReindexServiceAccount string - ReindexMemoryLimit string - ReindexCPULimit string - MaxConcurrentReindexJobs int - ActivityImage string - - // JobTemplateConfigMap specifies the ConfigMap containing the Job template. - // Format: namespace/name. If not set, a default template is used. - JobTemplateConfigMap string - - Logs *logsapi.LoggingConfiguration -} - -// NewControllerManagerOptions creates options with default values. -func NewControllerManagerOptions() *ControllerManagerOptions { - return &ControllerManagerOptions{ - Logs: logsapi.NewLoggingConfiguration(), - Workers: 2, - MetricsAddr: ":8080", - HealthProbeAddr: ":8081", - ReindexJobNamespace: "activity-system", - ReindexServiceAccount: "activity-reindex-worker", - ReindexMemoryLimit: "2Gi", - ReindexCPULimit: "1000m", - MaxConcurrentReindexJobs: 1, - ActivityImage: "ghcr.io/datum-cloud/activity:latest", - } -} - -// AddFlags adds controller manager flags to the command. -func (o *ControllerManagerOptions) AddFlags(fs *pflag.FlagSet) { - fs.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, - "Path to a kubeconfig file for Milo API server. Only required if out-of-cluster.") - fs.StringVar(&o.JobKubeconfig, "job-kubeconfig", o.JobKubeconfig, - "Path to a kubeconfig file for infrastructure cluster where Jobs run. If not set, uses --kubeconfig or in-cluster config.") - fs.StringVar(&o.MasterURL, "master", o.MasterURL, - "The address of the Kubernetes API server. Overrides any value in kubeconfig.") - fs.IntVar(&o.Workers, "workers", o.Workers, - "Number of worker threads for the controller.") - fs.StringVar(&o.MetricsAddr, "metrics-addr", o.MetricsAddr, - "The address to bind the metrics endpoint.") - fs.StringVar(&o.HealthProbeAddr, "health-probe-addr", o.HealthProbeAddr, - "The address to bind the health probe endpoint.") - - // NATS flags (required) - fs.StringVar(&o.NATSURL, "nats-url", o.NATSURL, - "NATS server URL (e.g., nats://localhost:4222). Required.") - fs.BoolVar(&o.NATSTLSEnabled, "nats-tls-enabled", o.NATSTLSEnabled, - "Enable TLS for NATS connection.") - fs.StringVar(&o.NATSTLSCertFile, "nats-tls-cert-file", o.NATSTLSCertFile, - "Path to client certificate file for NATS TLS.") - fs.StringVar(&o.NATSTLSKeyFile, "nats-tls-key-file", o.NATSTLSKeyFile, - "Path to client private key file for NATS TLS.") - fs.StringVar(&o.NATSTLSCAFile, "nats-tls-ca-file", o.NATSTLSCAFile, - "Path to CA certificate file for NATS TLS.") - - // ReindexJob flags - fs.StringVar(&o.ReindexJobNamespace, "reindex-job-namespace", o.ReindexJobNamespace, - "Namespace where ReindexJob worker Jobs are created.") - fs.StringVar(&o.ReindexServiceAccount, "reindex-service-account", o.ReindexServiceAccount, - "ServiceAccount for ReindexJob worker pods.") - fs.StringVar(&o.ReindexMemoryLimit, "reindex-memory-limit", o.ReindexMemoryLimit, - "Memory limit for ReindexJob worker pods (e.g., 2Gi).") - fs.StringVar(&o.ReindexCPULimit, "reindex-cpu-limit", o.ReindexCPULimit, - "CPU limit for ReindexJob worker pods (e.g., 1000m).") - fs.IntVar(&o.MaxConcurrentReindexJobs, "max-concurrent-reindex-jobs", o.MaxConcurrentReindexJobs, - "Maximum number of concurrent ReindexJobs allowed.") - fs.StringVar(&o.ActivityImage, "activity-image", o.ActivityImage, - "Container image for activity binary used by ReindexJob workers.") - fs.StringVar(&o.JobTemplateConfigMap, "reindex-job-template-configmap", o.JobTemplateConfigMap, - "ConfigMap containing the Job template for reindex workers (format: namespace/name). "+ - "The ConfigMap should have a 'template.yaml' key with a PodTemplateSpec. "+ - "If not set, a default template is used.") - - logsapi.AddFlags(o.Logs, fs) -} - -// NewControllerManagerCommand creates the controller-manager subcommand. -func NewControllerManagerCommand() *cobra.Command { - options := NewControllerManagerOptions() - - cmd := &cobra.Command{ - Use: "controller-manager", - Short: "Run the controller manager", - Long: `Run the controller manager that watches for changes to Activity resources -and reconciles the desired state. This includes managing ActivityPolicy resources -and ensuring consistent state across the cluster.`, - RunE: func(cmd *cobra.Command, args []string) error { - if err := logsapi.ValidateAndApply(options.Logs, utilfeature.DefaultMutableFeatureGate); err != nil { - return fmt.Errorf("failed to apply logging configuration: %w", err) - } - ctrl.SetLogger(klog.NewKlogr()) - return RunControllerManager(options) - }, - } - - options.AddFlags(cmd.Flags()) - - return cmd -} - -// RunControllerManager starts the controller manager. -func RunControllerManager(options *ControllerManagerOptions) error { - // Validate required flags - if options.NATSURL == "" { - return fmt.Errorf("--nats-url is required") - } - - klog.Info("Starting Activity Controller Manager") - - // Build the client configuration for Milo API server (ReindexJob, ActivityPolicy) - var config *rest.Config - var err error - - if options.Kubeconfig != "" { - config, err = clientcmd.BuildConfigFromFlags(options.MasterURL, options.Kubeconfig) - } else { - config, err = rest.InClusterConfig() - } - if err != nil { - return fmt.Errorf("failed to build config for Milo API server: %w", err) - } - - // Build the client configuration for infrastructure cluster (Jobs) - // Priority: --job-kubeconfig > in-cluster config > same as main config - var jobConfig *rest.Config - if options.JobKubeconfig != "" { - // Use explicit kubeconfig for Job operations - jobConfig, err = clientcmd.BuildConfigFromFlags("", options.JobKubeconfig) - if err != nil { - return fmt.Errorf("failed to build config from --job-kubeconfig: %w", err) - } - klog.Info("Using explicit kubeconfig for Job operations", "path", options.JobKubeconfig) - } else if inClusterConfig, inClusterErr := rest.InClusterConfig(); inClusterErr == nil { - // Running in a cluster - use in-cluster config for Jobs - // This allows the controller to create Jobs in the infrastructure cluster - // while connecting to Milo for ReindexJob CRs - jobConfig = inClusterConfig - klog.Info("Using in-cluster config for Job operations") - } else { - // Not in a cluster and no --job-kubeconfig - use same config as main client - // This is the typical dev environment scenario - jobConfig = config - klog.Info("Using same kubeconfig for Job operations (dev mode)") - } - - // Create a client for Job operations - jobClient, err := client.New(jobConfig, client.Options{ - Scheme: controller.Scheme, - }) - if err != nil { - return fmt.Errorf("failed to create Job client: %w", err) - } - - // Load Job template from ConfigMap if specified - var jobTemplate *corev1.PodTemplateSpec - if options.JobTemplateConfigMap != "" { - namespace, name, parseErr := parseNamespacedName(options.JobTemplateConfigMap) - if parseErr != nil { - return fmt.Errorf("invalid --reindex-job-template-configmap value: %w", parseErr) - } - - ctx := context.Background() - jobTemplate, err = controller.LoadJobTemplate(ctx, jobClient, namespace, name) - if err != nil { - return fmt.Errorf("failed to load job template: %w", err) - } - klog.Info("Loaded job template from ConfigMap", "namespace", namespace, "name", name) - } - - managerOpts := controller.ManagerOptions{ - Workers: options.Workers, - MetricsAddr: options.MetricsAddr, - HealthProbeAddr: options.HealthProbeAddr, - JobClient: jobClient, - ReindexJobNamespace: options.ReindexJobNamespace, - ReindexServiceAccount: options.ReindexServiceAccount, - ReindexMemoryLimit: options.ReindexMemoryLimit, - ReindexCPULimit: options.ReindexCPULimit, - MaxConcurrentReindexJobs: options.MaxConcurrentReindexJobs, - ActivityImage: options.ActivityImage, - JobTemplate: jobTemplate, - NATSURL: options.NATSURL, - NATSTLSEnabled: options.NATSTLSEnabled, - NATSTLSCertFile: options.NATSTLSCertFile, - NATSTLSKeyFile: options.NATSTLSKeyFile, - NATSTLSCAFile: options.NATSTLSCAFile, - } - - // Initialize NATS JetStream connection - klog.Info("Initializing NATS JetStream connection") - - var natsOpts []nats.Option - - // Configure TLS if enabled - if options.NATSTLSEnabled { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - // Load client cert/key if provided - if options.NATSTLSCertFile != "" && options.NATSTLSKeyFile != "" { - cert, err := tls.LoadX509KeyPair(options.NATSTLSCertFile, options.NATSTLSKeyFile) - if err != nil { - return fmt.Errorf("failed to load NATS TLS client cert: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - } - - // Load CA cert if provided - if options.NATSTLSCAFile != "" { - caCert, err := os.ReadFile(options.NATSTLSCAFile) - if err != nil { - return fmt.Errorf("failed to read NATS TLS CA file: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return fmt.Errorf("failed to parse NATS TLS CA certificate") - } - tlsConfig.RootCAs = caCertPool - } - - natsOpts = append(natsOpts, nats.Secure(tlsConfig)) - } - - natsConn, err := nats.Connect(options.NATSURL, natsOpts...) - if err != nil { - return fmt.Errorf("failed to connect to NATS: %w", err) - } - - js, err := natsConn.JetStream() - if err != nil { - natsConn.Close() - return fmt.Errorf("failed to get JetStream context: %w", err) - } - managerOpts.JetStream = js - klog.Info("NATS JetStream initialized", "url", options.NATSURL) - - // Create the controller manager - mgr, err := controller.NewManager(config, managerOpts) - if err != nil { - return err - } - - // Use controller-runtime's signal handler for graceful shutdown - ctx := ctrl.SetupSignalHandler() - - // Run the controller manager - runErr := controller.Run(ctx, mgr) - - // Clean up NATS connection on shutdown - klog.Info("Closing NATS connection") - natsConn.Close() - - if runErr != nil { - return runErr - } - - klog.Info("Controller manager shutdown complete") - return nil -} - -// parseNamespacedName parses a string in the format "namespace/name" into its components. -func parseNamespacedName(s string) (namespace, name string, err error) { - parts := strings.SplitN(s, "/", 2) - if len(parts) != 2 { - return "", "", fmt.Errorf("expected format 'namespace/name', got %q", s) - } - if parts[0] == "" || parts[1] == "" { - return "", "", fmt.Errorf("namespace and name cannot be empty in %q", s) - } - return parts[0], parts[1], nil -} +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "strings" + + "github.com/nats-io/nats.go" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + corev1 "k8s.io/api/core/v1" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + logsapi "k8s.io/component-base/logs/api/v1" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.miloapis.com/activity/internal/controller" +) + +// ControllerManagerOptions contains configuration for the controller manager. +type ControllerManagerOptions struct { + Kubeconfig string + JobKubeconfig string + MasterURL string + Workers int + MetricsAddr string + HealthProbeAddr string + + // NATS configuration (required) + NATSURL string + NATSTLSEnabled bool + NATSTLSCertFile string + NATSTLSKeyFile string + NATSTLSCAFile string + + // ReindexJob configuration + ReindexJobNamespace string + ReindexServiceAccount string + ReindexMemoryLimit string + ReindexCPULimit string + MaxConcurrentReindexJobs int + ActivityImage string + + // JobTemplateConfigMap specifies the ConfigMap containing the Job template. + // Format: namespace/name. If not set, a default template is used. + JobTemplateConfigMap string + + Logs *logsapi.LoggingConfiguration +} + +// NewControllerManagerOptions creates options with default values. +func NewControllerManagerOptions() *ControllerManagerOptions { + return &ControllerManagerOptions{ + Logs: logsapi.NewLoggingConfiguration(), + Workers: 2, + MetricsAddr: ":8080", + HealthProbeAddr: ":8081", + ReindexJobNamespace: "activity-system", + ReindexServiceAccount: "activity-reindex-worker", + ReindexMemoryLimit: "2Gi", + ReindexCPULimit: "1000m", + MaxConcurrentReindexJobs: 1, + ActivityImage: "ghcr.io/datum-cloud/activity:latest", + } +} + +// AddFlags adds controller manager flags to the command. +func (o *ControllerManagerOptions) AddFlags(fs *pflag.FlagSet) { + fs.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, + "Path to a kubeconfig file for Milo API server. Only required if out-of-cluster.") + fs.StringVar(&o.JobKubeconfig, "job-kubeconfig", o.JobKubeconfig, + "Path to a kubeconfig file for infrastructure cluster where Jobs run. If not set, uses --kubeconfig or in-cluster config.") + fs.StringVar(&o.MasterURL, "master", o.MasterURL, + "The address of the Kubernetes API server. Overrides any value in kubeconfig.") + fs.IntVar(&o.Workers, "workers", o.Workers, + "Number of worker threads for the controller.") + fs.StringVar(&o.MetricsAddr, "metrics-addr", o.MetricsAddr, + "The address to bind the metrics endpoint.") + fs.StringVar(&o.HealthProbeAddr, "health-probe-addr", o.HealthProbeAddr, + "The address to bind the health probe endpoint.") + + // NATS flags (required) + fs.StringVar(&o.NATSURL, "nats-url", o.NATSURL, + "NATS server URL (e.g., nats://localhost:4222). Required.") + fs.BoolVar(&o.NATSTLSEnabled, "nats-tls-enabled", o.NATSTLSEnabled, + "Enable TLS for NATS connection.") + fs.StringVar(&o.NATSTLSCertFile, "nats-tls-cert-file", o.NATSTLSCertFile, + "Path to client certificate file for NATS TLS.") + fs.StringVar(&o.NATSTLSKeyFile, "nats-tls-key-file", o.NATSTLSKeyFile, + "Path to client private key file for NATS TLS.") + fs.StringVar(&o.NATSTLSCAFile, "nats-tls-ca-file", o.NATSTLSCAFile, + "Path to CA certificate file for NATS TLS.") + + // ReindexJob flags + fs.StringVar(&o.ReindexJobNamespace, "reindex-job-namespace", o.ReindexJobNamespace, + "Namespace where ReindexJob worker Jobs are created.") + fs.StringVar(&o.ReindexServiceAccount, "reindex-service-account", o.ReindexServiceAccount, + "ServiceAccount for ReindexJob worker pods.") + fs.StringVar(&o.ReindexMemoryLimit, "reindex-memory-limit", o.ReindexMemoryLimit, + "Memory limit for ReindexJob worker pods (e.g., 2Gi).") + fs.StringVar(&o.ReindexCPULimit, "reindex-cpu-limit", o.ReindexCPULimit, + "CPU limit for ReindexJob worker pods (e.g., 1000m).") + fs.IntVar(&o.MaxConcurrentReindexJobs, "max-concurrent-reindex-jobs", o.MaxConcurrentReindexJobs, + "Maximum number of concurrent ReindexJobs allowed.") + fs.StringVar(&o.ActivityImage, "activity-image", o.ActivityImage, + "Container image for activity binary used by ReindexJob workers.") + fs.StringVar(&o.JobTemplateConfigMap, "reindex-job-template-configmap", o.JobTemplateConfigMap, + "ConfigMap containing the Job template for reindex workers (format: namespace/name). "+ + "The ConfigMap should have a 'template.yaml' key with a PodTemplateSpec. "+ + "If not set, a default template is used.") + + logsapi.AddFlags(o.Logs, fs) +} + +// NewControllerManagerCommand creates the controller-manager subcommand. +func NewControllerManagerCommand() *cobra.Command { + options := NewControllerManagerOptions() + + cmd := &cobra.Command{ + Use: "controller-manager", + Short: "Run the controller manager", + Long: `Run the controller manager that watches for changes to Activity resources +and reconciles the desired state. This includes managing ActivityPolicy resources +and ensuring consistent state across the cluster.`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := logsapi.ValidateAndApply(options.Logs, utilfeature.DefaultMutableFeatureGate); err != nil { + return fmt.Errorf("failed to apply logging configuration: %w", err) + } + ctrl.SetLogger(klog.NewKlogr()) + return RunControllerManager(options) + }, + } + + options.AddFlags(cmd.Flags()) + + return cmd +} + +// RunControllerManager starts the controller manager. +func RunControllerManager(options *ControllerManagerOptions) error { + // Validate required flags + if options.NATSURL == "" { + return fmt.Errorf("--nats-url is required") + } + + klog.Info("Starting Activity Controller Manager") + + // Build the client configuration for Milo API server (ReindexJob, ActivityPolicy) + var config *rest.Config + var err error + + if options.Kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags(options.MasterURL, options.Kubeconfig) + } else { + config, err = rest.InClusterConfig() + } + if err != nil { + return fmt.Errorf("failed to build config for Milo API server: %w", err) + } + + // Build the client configuration for infrastructure cluster (Jobs) + // Priority: --job-kubeconfig > in-cluster config > same as main config + var jobConfig *rest.Config + if options.JobKubeconfig != "" { + // Use explicit kubeconfig for Job operations + jobConfig, err = clientcmd.BuildConfigFromFlags("", options.JobKubeconfig) + if err != nil { + return fmt.Errorf("failed to build config from --job-kubeconfig: %w", err) + } + klog.Info("Using explicit kubeconfig for Job operations", "path", options.JobKubeconfig) + } else if inClusterConfig, inClusterErr := rest.InClusterConfig(); inClusterErr == nil { + // Running in a cluster - use in-cluster config for Jobs + // This allows the controller to create Jobs in the infrastructure cluster + // while connecting to Milo for ReindexJob CRs + jobConfig = inClusterConfig + klog.Info("Using in-cluster config for Job operations") + } else { + // Not in a cluster and no --job-kubeconfig - use same config as main client + // This is the typical dev environment scenario + jobConfig = config + klog.Info("Using same kubeconfig for Job operations (dev mode)") + } + + // Create a client for Job operations + jobClient, err := client.New(jobConfig, client.Options{ + Scheme: controller.Scheme, + }) + if err != nil { + return fmt.Errorf("failed to create Job client: %w", err) + } + + // Load Job template from ConfigMap if specified + var jobTemplate *corev1.PodTemplateSpec + if options.JobTemplateConfigMap != "" { + namespace, name, parseErr := parseNamespacedName(options.JobTemplateConfigMap) + if parseErr != nil { + return fmt.Errorf("invalid --reindex-job-template-configmap value: %w", parseErr) + } + + ctx := context.Background() + jobTemplate, err = controller.LoadJobTemplate(ctx, jobClient, namespace, name) + if err != nil { + return fmt.Errorf("failed to load job template: %w", err) + } + klog.Info("Loaded job template from ConfigMap", "namespace", namespace, "name", name) + } + + managerOpts := controller.ManagerOptions{ + Workers: options.Workers, + MetricsAddr: options.MetricsAddr, + HealthProbeAddr: options.HealthProbeAddr, + JobClient: jobClient, + ReindexJobNamespace: options.ReindexJobNamespace, + ReindexServiceAccount: options.ReindexServiceAccount, + ReindexMemoryLimit: options.ReindexMemoryLimit, + ReindexCPULimit: options.ReindexCPULimit, + MaxConcurrentReindexJobs: options.MaxConcurrentReindexJobs, + ActivityImage: options.ActivityImage, + JobTemplate: jobTemplate, + NATSURL: options.NATSURL, + NATSTLSEnabled: options.NATSTLSEnabled, + NATSTLSCertFile: options.NATSTLSCertFile, + NATSTLSKeyFile: options.NATSTLSKeyFile, + NATSTLSCAFile: options.NATSTLSCAFile, + } + + // Initialize NATS JetStream connection + klog.Info("Initializing NATS JetStream connection") + + var natsOpts []nats.Option + + // Configure TLS if enabled + if options.NATSTLSEnabled { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Load client cert/key if provided + if options.NATSTLSCertFile != "" && options.NATSTLSKeyFile != "" { + cert, err := tls.LoadX509KeyPair(options.NATSTLSCertFile, options.NATSTLSKeyFile) + if err != nil { + return fmt.Errorf("failed to load NATS TLS client cert: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + // Load CA cert if provided + if options.NATSTLSCAFile != "" { + caCert, err := os.ReadFile(options.NATSTLSCAFile) + if err != nil { + return fmt.Errorf("failed to read NATS TLS CA file: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return fmt.Errorf("failed to parse NATS TLS CA certificate") + } + tlsConfig.RootCAs = caCertPool + } + + natsOpts = append(natsOpts, nats.Secure(tlsConfig)) + } + + natsConn, err := nats.Connect(options.NATSURL, natsOpts...) + if err != nil { + return fmt.Errorf("failed to connect to NATS: %w", err) + } + + js, err := natsConn.JetStream() + if err != nil { + natsConn.Close() + return fmt.Errorf("failed to get JetStream context: %w", err) + } + managerOpts.JetStream = js + klog.Info("NATS JetStream initialized", "url", options.NATSURL) + + // Create the controller manager + mgr, err := controller.NewManager(config, managerOpts) + if err != nil { + return err + } + + // Use controller-runtime's signal handler for graceful shutdown + ctx := ctrl.SetupSignalHandler() + + // Run the controller manager + runErr := controller.Run(ctx, mgr) + + // Clean up NATS connection on shutdown + klog.Info("Closing NATS connection") + natsConn.Close() + + if runErr != nil { + return runErr + } + + klog.Info("Controller manager shutdown complete") + return nil +} + +// parseNamespacedName parses a string in the format "namespace/name" into its components. +func parseNamespacedName(s string) (namespace, name string, err error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("expected format 'namespace/name', got %q", s) + } + if parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("namespace and name cannot be empty in %q", s) + } + return parts[0], parts[1], nil +} diff --git a/cmd/activity/reindex_worker.go b/cmd/activity/reindex_worker.go index 6b3703ad..a314299f 100644 --- a/cmd/activity/reindex_worker.go +++ b/cmd/activity/reindex_worker.go @@ -1,411 +1,411 @@ -package main - -import ( - "context" - "crypto/tls" - "crypto/x509" - "fmt" - "os" - "time" - - "github.com/nats-io/nats.go" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - utilfeature "k8s.io/apiserver/pkg/util/feature" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - logsapi "k8s.io/component-base/logs/api/v1" - "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/client" - - "go.miloapis.com/activity/internal/controller" - "go.miloapis.com/activity/internal/reindex" - "go.miloapis.com/activity/internal/timeutil" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -const ( - retentionWindow = 60 * 24 * time.Hour -) - -// ReindexWorkerOptions contains configuration for the reindex worker. -type ReindexWorkerOptions struct { - // The ReindexJob resource name - JobName string - - // Kubeconfig for connecting to the API server where ReindexJob resources live. - // If empty, uses in-cluster config. - Kubeconfig string - - // NATS configuration (required for publishing activities) - NATSURL string - NATSTLSEnabled bool - NATSTLSCertFile string - NATSTLSKeyFile string - NATSTLSCAFile string - - Logs *logsapi.LoggingConfiguration -} - -// NewReindexWorkerOptions creates options with default values. -func NewReindexWorkerOptions() *ReindexWorkerOptions { - return &ReindexWorkerOptions{ - Logs: logsapi.NewLoggingConfiguration(), - } -} - -// AddFlags adds reindex worker flags to the command. -func (o *ReindexWorkerOptions) AddFlags(fs *pflag.FlagSet) { - // Kubernetes API flags - fs.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, - "Path to kubeconfig file for connecting to the API server. If empty, uses in-cluster config.") - - // NATS flags (required) - fs.StringVar(&o.NATSURL, "nats-url", o.NATSURL, - "NATS server URL (e.g., nats://localhost:4222). Required.") - fs.BoolVar(&o.NATSTLSEnabled, "nats-tls-enabled", o.NATSTLSEnabled, - "Enable TLS for NATS connection.") - fs.StringVar(&o.NATSTLSCertFile, "nats-tls-cert-file", o.NATSTLSCertFile, - "Path to client certificate file for NATS TLS.") - fs.StringVar(&o.NATSTLSKeyFile, "nats-tls-key-file", o.NATSTLSKeyFile, - "Path to client private key file for NATS TLS.") - fs.StringVar(&o.NATSTLSCAFile, "nats-tls-ca-file", o.NATSTLSCAFile, - "Path to CA certificate file for NATS TLS.") - - logsapi.AddFlags(o.Logs, fs) -} - -// NewReindexWorkerCommand creates the reindex-worker subcommand. -func NewReindexWorkerCommand() *cobra.Command { - options := NewReindexWorkerOptions() - - cmd := &cobra.Command{ - Use: "reindex-worker ", - Short: "Run a single ReindexJob worker", - Long: `Run the reindex worker for a specific ReindexJob resource. -This is executed by Kubernetes Jobs created by the controller-manager. -It should not be run manually.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - options.JobName = args[0] - return RunReindexWorker(cmd.Context(), options) - }, - } - - options.AddFlags(cmd.Flags()) - - return cmd -} - -// RunReindexWorker executes the reindex job worker. -func RunReindexWorker(ctx context.Context, options *ReindexWorkerOptions) error { - if err := logsapi.ValidateAndApply(options.Logs, utilfeature.DefaultMutableFeatureGate); err != nil { - return fmt.Errorf("failed to apply logging configuration: %w", err) - } - - // Validate required flags - if options.NATSURL == "" { - return fmt.Errorf("--nats-url is required") - } - - if options.JobName == "" { - return fmt.Errorf("reindexjob name is required") - } - - klog.InfoS("Starting reindex worker", "job", options.JobName) - - // Build Kubernetes client config - var config *rest.Config - var err error - if options.Kubeconfig != "" { - config, err = clientcmd.BuildConfigFromFlags("", options.Kubeconfig) - if err != nil { - return fmt.Errorf("failed to build config from kubeconfig: %w", err) - } - klog.InfoS("Using kubeconfig", "path", options.Kubeconfig) - } else { - config, err = rest.InClusterConfig() - if err != nil { - return fmt.Errorf("failed to get in-cluster config: %w", err) - } - klog.InfoS("Using in-cluster config") - } - - // Create Kubernetes client - scheme := controller.Scheme - cl, err := client.New(config, client.Options{Scheme: scheme}) - if err != nil { - return fmt.Errorf("failed to create Kubernetes client: %w", err) - } - - // Fetch the ReindexJob resource - var job v1alpha1.ReindexJob - if err := cl.Get(ctx, types.NamespacedName{Name: options.JobName}, &job); err != nil { - return fmt.Errorf("failed to fetch ReindexJob %s: %w", options.JobName, err) - } - - klog.InfoS("Loaded ReindexJob", "name", job.Name, "generation", job.Generation) - - // Initialize NATS JetStream connection - klog.InfoS("Connecting to NATS", "url", options.NATSURL) - natsOpts, err := buildNATSOptions(options) - if err != nil { - return fmt.Errorf("failed to build NATS options: %w", err) - } - - natsConn, err := nats.Connect(options.NATSURL, natsOpts...) - if err != nil { - return fmt.Errorf("failed to connect to NATS: %w", err) - } - defer natsConn.Close() - - js, err := natsConn.JetStream() - if err != nil { - return fmt.Errorf("failed to get JetStream context: %w", err) - } - - // Update status to Running - if err := updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobRunning, "Re-indexing in progress"); err != nil { - return fmt.Errorf("failed to update status to Running: %w", err) - } - - // Create reindexer - reindexer := reindex.NewReindexer(cl, js) - - // Set up progress callback to update ReindexJob status - reindexer.OnProgress = func(progress reindex.Progress) { - if err := updateJobProgress(ctx, cl, &job, progress); err != nil { - klog.V(2).InfoS("failed to update progress (will retry on next batch)", "error", err) - } - } - - // Parse time range - now := time.Now() - startTime, err := timeutil.ParseFlexibleTime(job.Spec.TimeRange.StartTime, now) - if err != nil { - updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, fmt.Sprintf("Invalid startTime: %v", err)) - return fmt.Errorf("invalid startTime: %w", err) - } - - endTimeStr := job.Spec.TimeRange.EndTime - if endTimeStr == "" { - endTimeStr = "now" - } - endTime, err := timeutil.ParseFlexibleTime(endTimeStr, now) - if err != nil { - updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, fmt.Sprintf("Invalid endTime: %v", err)) - return fmt.Errorf("invalid endTime: %w", err) - } - - // Validate retention window - timeSinceStart := time.Since(startTime) - if timeSinceStart > retentionWindow { - msg := fmt.Sprintf( - "startTime exceeds ClickHouse retention window: data from %s is beyond the %d-day retention period (age: %dd)", - startTime.Format(time.RFC3339), - int(retentionWindow.Hours()/24), - int(timeSinceStart.Hours()/24), - ) - updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, msg) - return fmt.Errorf("retention window exceeded") - } - - // Build reindex options - batchSize := int32(1000) - if job.Spec.Config != nil && job.Spec.Config.BatchSize > 0 { - batchSize = job.Spec.Config.BatchSize - } - - rateLimit := int32(100) - if job.Spec.Config != nil && job.Spec.Config.RateLimit > 0 { - rateLimit = job.Spec.Config.RateLimit - } - - dryRun := false - if job.Spec.Config != nil { - dryRun = job.Spec.Config.DryRun - } - - var policyNames []string - var matchLabels map[string]string - if job.Spec.PolicySelector != nil { - if len(job.Spec.PolicySelector.Names) > 0 { - policyNames = job.Spec.PolicySelector.Names - } - if len(job.Spec.PolicySelector.MatchLabels) > 0 { - matchLabels = job.Spec.PolicySelector.MatchLabels - } - } - - opts := reindex.Options{ - StartTime: startTime, - EndTime: endTime, - BatchSize: batchSize, - RateLimit: rateLimit, - DryRun: dryRun, - PolicyNames: policyNames, - MatchLabels: matchLabels, - } - - klog.InfoS("Starting reindex operation", - "startTime", opts.StartTime, - "endTime", opts.EndTime, - "batchSize", opts.BatchSize, - "rateLimit", opts.RateLimit, - "dryRun", opts.DryRun, - ) - - // Run the reindexer - runErr := reindexer.Run(ctx, opts) - - // Update final status - if runErr != nil { - msg := fmt.Sprintf("Re-indexing failed: %v", runErr) - if err := updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, msg); err != nil { - klog.ErrorS(err, "failed to update status to Failed") - } - return runErr - } - - // Success - var finalJob v1alpha1.ReindexJob - if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &finalJob); err == nil { - activitiesGenerated := int64(0) - if finalJob.Status.Progress != nil { - activitiesGenerated = finalJob.Status.Progress.ActivitiesGenerated - } - - var msg string - if dryRun { - msg = fmt.Sprintf("Dry-run complete: %d activities would be generated", activitiesGenerated) - } else { - msg = fmt.Sprintf("Completed: %d activities generated", activitiesGenerated) - } - - if err := updateJobPhase(ctx, cl, &finalJob, v1alpha1.ReindexJobSucceeded, msg); err != nil { - klog.ErrorS(err, "failed to update status to Succeeded") - } - - klog.InfoS("Reindex job completed successfully", - "activitiesGenerated", activitiesGenerated, - ) - } - - return nil -} - -// updateJobPhase updates the ReindexJob phase and message, setting CompletedAt for terminal phases. -func updateJobPhase(ctx context.Context, cl client.Client, job *v1alpha1.ReindexJob, phase v1alpha1.ReindexJobPhase, message string) error { - // Fetch latest version - var latest v1alpha1.ReindexJob - if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &latest); err != nil { - return err - } - - latest.Status.Phase = phase - latest.Status.Message = message - - // Set CompletedAt for terminal phases - if phase == v1alpha1.ReindexJobSucceeded || phase == v1alpha1.ReindexJobFailed { - now := metav1.Now() - latest.Status.CompletedAt = &now - } - - // Set StartedAt for Running phase if not already set - if phase == v1alpha1.ReindexJobRunning && latest.Status.StartedAt == nil { - now := metav1.Now() - latest.Status.StartedAt = &now - } - - // Update conditions - var conditionStatus metav1.ConditionStatus - var reason string - switch phase { - case v1alpha1.ReindexJobPending: - conditionStatus = metav1.ConditionFalse - reason = "Pending" - case v1alpha1.ReindexJobRunning: - conditionStatus = metav1.ConditionFalse - reason = "InProgress" - case v1alpha1.ReindexJobSucceeded: - conditionStatus = metav1.ConditionTrue - reason = "Succeeded" - case v1alpha1.ReindexJobFailed: - conditionStatus = metav1.ConditionFalse - reason = "Failed" - } - - meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ - Type: "Ready", - Status: conditionStatus, - Reason: reason, - Message: message, - ObservedGeneration: latest.Generation, - }) - - return cl.Status().Update(ctx, &latest) -} - -// updateJobProgress updates the ReindexJob progress information. -func updateJobProgress(ctx context.Context, cl client.Client, job *v1alpha1.ReindexJob, progress reindex.Progress) error { - // Fetch latest version - var latest v1alpha1.ReindexJob - if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &latest); err != nil { - return err - } - - latest.Status.Progress = &v1alpha1.ReindexProgress{ - TotalEvents: progress.TotalEvents, - ProcessedEvents: progress.ProcessedEvents, - ActivitiesGenerated: progress.ActivitiesGenerated, - Errors: progress.Errors, - CurrentBatch: progress.CurrentBatch, - TotalBatches: progress.TotalBatches, - } - - latest.Status.Message = fmt.Sprintf("Processing: %d events processed, %d activities generated", - progress.ProcessedEvents, progress.ActivitiesGenerated) - - return cl.Status().Update(ctx, &latest) -} - -// buildNATSOptions constructs NATS connection options from configuration. -func buildNATSOptions(options *ReindexWorkerOptions) ([]nats.Option, error) { - var natsOpts []nats.Option - - // Configure TLS if enabled - if options.NATSTLSEnabled { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - // Load client cert/key if provided - if options.NATSTLSCertFile != "" && options.NATSTLSKeyFile != "" { - cert, err := tls.LoadX509KeyPair(options.NATSTLSCertFile, options.NATSTLSKeyFile) - if err != nil { - return nil, fmt.Errorf("failed to load NATS TLS client cert: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - } - - // Load CA cert if provided - if options.NATSTLSCAFile != "" { - caCert, err := os.ReadFile(options.NATSTLSCAFile) - if err != nil { - return nil, fmt.Errorf("failed to read NATS TLS CA file: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse NATS TLS CA certificate") - } - tlsConfig.RootCAs = caCertPool - } - - natsOpts = append(natsOpts, nats.Secure(tlsConfig)) - } - - return natsOpts, nil -} +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "time" + + "github.com/nats-io/nats.go" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + utilfeature "k8s.io/apiserver/pkg/util/feature" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + logsapi "k8s.io/component-base/logs/api/v1" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.miloapis.com/activity/internal/controller" + "go.miloapis.com/activity/internal/reindex" + "go.miloapis.com/activity/internal/timeutil" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +const ( + retentionWindow = 60 * 24 * time.Hour +) + +// ReindexWorkerOptions contains configuration for the reindex worker. +type ReindexWorkerOptions struct { + // The ReindexJob resource name + JobName string + + // Kubeconfig for connecting to the API server where ReindexJob resources live. + // If empty, uses in-cluster config. + Kubeconfig string + + // NATS configuration (required for publishing activities) + NATSURL string + NATSTLSEnabled bool + NATSTLSCertFile string + NATSTLSKeyFile string + NATSTLSCAFile string + + Logs *logsapi.LoggingConfiguration +} + +// NewReindexWorkerOptions creates options with default values. +func NewReindexWorkerOptions() *ReindexWorkerOptions { + return &ReindexWorkerOptions{ + Logs: logsapi.NewLoggingConfiguration(), + } +} + +// AddFlags adds reindex worker flags to the command. +func (o *ReindexWorkerOptions) AddFlags(fs *pflag.FlagSet) { + // Kubernetes API flags + fs.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, + "Path to kubeconfig file for connecting to the API server. If empty, uses in-cluster config.") + + // NATS flags (required) + fs.StringVar(&o.NATSURL, "nats-url", o.NATSURL, + "NATS server URL (e.g., nats://localhost:4222). Required.") + fs.BoolVar(&o.NATSTLSEnabled, "nats-tls-enabled", o.NATSTLSEnabled, + "Enable TLS for NATS connection.") + fs.StringVar(&o.NATSTLSCertFile, "nats-tls-cert-file", o.NATSTLSCertFile, + "Path to client certificate file for NATS TLS.") + fs.StringVar(&o.NATSTLSKeyFile, "nats-tls-key-file", o.NATSTLSKeyFile, + "Path to client private key file for NATS TLS.") + fs.StringVar(&o.NATSTLSCAFile, "nats-tls-ca-file", o.NATSTLSCAFile, + "Path to CA certificate file for NATS TLS.") + + logsapi.AddFlags(o.Logs, fs) +} + +// NewReindexWorkerCommand creates the reindex-worker subcommand. +func NewReindexWorkerCommand() *cobra.Command { + options := NewReindexWorkerOptions() + + cmd := &cobra.Command{ + Use: "reindex-worker ", + Short: "Run a single ReindexJob worker", + Long: `Run the reindex worker for a specific ReindexJob resource. +This is executed by Kubernetes Jobs created by the controller-manager. +It should not be run manually.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + options.JobName = args[0] + return RunReindexWorker(cmd.Context(), options) + }, + } + + options.AddFlags(cmd.Flags()) + + return cmd +} + +// RunReindexWorker executes the reindex job worker. +func RunReindexWorker(ctx context.Context, options *ReindexWorkerOptions) error { + if err := logsapi.ValidateAndApply(options.Logs, utilfeature.DefaultMutableFeatureGate); err != nil { + return fmt.Errorf("failed to apply logging configuration: %w", err) + } + + // Validate required flags + if options.NATSURL == "" { + return fmt.Errorf("--nats-url is required") + } + + if options.JobName == "" { + return fmt.Errorf("reindexjob name is required") + } + + klog.InfoS("Starting reindex worker", "job", options.JobName) + + // Build Kubernetes client config + var config *rest.Config + var err error + if options.Kubeconfig != "" { + config, err = clientcmd.BuildConfigFromFlags("", options.Kubeconfig) + if err != nil { + return fmt.Errorf("failed to build config from kubeconfig: %w", err) + } + klog.InfoS("Using kubeconfig", "path", options.Kubeconfig) + } else { + config, err = rest.InClusterConfig() + if err != nil { + return fmt.Errorf("failed to get in-cluster config: %w", err) + } + klog.InfoS("Using in-cluster config") + } + + // Create Kubernetes client + scheme := controller.Scheme + cl, err := client.New(config, client.Options{Scheme: scheme}) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + // Fetch the ReindexJob resource + var job v1alpha1.ReindexJob + if err := cl.Get(ctx, types.NamespacedName{Name: options.JobName}, &job); err != nil { + return fmt.Errorf("failed to fetch ReindexJob %s: %w", options.JobName, err) + } + + klog.InfoS("Loaded ReindexJob", "name", job.Name, "generation", job.Generation) + + // Initialize NATS JetStream connection + klog.InfoS("Connecting to NATS", "url", options.NATSURL) + natsOpts, err := buildNATSOptions(options) + if err != nil { + return fmt.Errorf("failed to build NATS options: %w", err) + } + + natsConn, err := nats.Connect(options.NATSURL, natsOpts...) + if err != nil { + return fmt.Errorf("failed to connect to NATS: %w", err) + } + defer natsConn.Close() + + js, err := natsConn.JetStream() + if err != nil { + return fmt.Errorf("failed to get JetStream context: %w", err) + } + + // Update status to Running + if err := updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobRunning, "Re-indexing in progress"); err != nil { + return fmt.Errorf("failed to update status to Running: %w", err) + } + + // Create reindexer + reindexer := reindex.NewReindexer(cl, js) + + // Set up progress callback to update ReindexJob status + reindexer.OnProgress = func(progress reindex.Progress) { + if err := updateJobProgress(ctx, cl, &job, progress); err != nil { + klog.V(2).InfoS("failed to update progress (will retry on next batch)", "error", err) + } + } + + // Parse time range + now := time.Now() + startTime, err := timeutil.ParseFlexibleTime(job.Spec.TimeRange.StartTime, now) + if err != nil { + updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, fmt.Sprintf("Invalid startTime: %v", err)) + return fmt.Errorf("invalid startTime: %w", err) + } + + endTimeStr := job.Spec.TimeRange.EndTime + if endTimeStr == "" { + endTimeStr = "now" + } + endTime, err := timeutil.ParseFlexibleTime(endTimeStr, now) + if err != nil { + updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, fmt.Sprintf("Invalid endTime: %v", err)) + return fmt.Errorf("invalid endTime: %w", err) + } + + // Validate retention window + timeSinceStart := time.Since(startTime) + if timeSinceStart > retentionWindow { + msg := fmt.Sprintf( + "startTime exceeds ClickHouse retention window: data from %s is beyond the %d-day retention period (age: %dd)", + startTime.Format(time.RFC3339), + int(retentionWindow.Hours()/24), + int(timeSinceStart.Hours()/24), + ) + updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, msg) + return fmt.Errorf("retention window exceeded") + } + + // Build reindex options + batchSize := int32(1000) + if job.Spec.Config != nil && job.Spec.Config.BatchSize > 0 { + batchSize = job.Spec.Config.BatchSize + } + + rateLimit := int32(100) + if job.Spec.Config != nil && job.Spec.Config.RateLimit > 0 { + rateLimit = job.Spec.Config.RateLimit + } + + dryRun := false + if job.Spec.Config != nil { + dryRun = job.Spec.Config.DryRun + } + + var policyNames []string + var matchLabels map[string]string + if job.Spec.PolicySelector != nil { + if len(job.Spec.PolicySelector.Names) > 0 { + policyNames = job.Spec.PolicySelector.Names + } + if len(job.Spec.PolicySelector.MatchLabels) > 0 { + matchLabels = job.Spec.PolicySelector.MatchLabels + } + } + + opts := reindex.Options{ + StartTime: startTime, + EndTime: endTime, + BatchSize: batchSize, + RateLimit: rateLimit, + DryRun: dryRun, + PolicyNames: policyNames, + MatchLabels: matchLabels, + } + + klog.InfoS("Starting reindex operation", + "startTime", opts.StartTime, + "endTime", opts.EndTime, + "batchSize", opts.BatchSize, + "rateLimit", opts.RateLimit, + "dryRun", opts.DryRun, + ) + + // Run the reindexer + runErr := reindexer.Run(ctx, opts) + + // Update final status + if runErr != nil { + msg := fmt.Sprintf("Re-indexing failed: %v", runErr) + if err := updateJobPhase(ctx, cl, &job, v1alpha1.ReindexJobFailed, msg); err != nil { + klog.ErrorS(err, "failed to update status to Failed") + } + return runErr + } + + // Success + var finalJob v1alpha1.ReindexJob + if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &finalJob); err == nil { + activitiesGenerated := int64(0) + if finalJob.Status.Progress != nil { + activitiesGenerated = finalJob.Status.Progress.ActivitiesGenerated + } + + var msg string + if dryRun { + msg = fmt.Sprintf("Dry-run complete: %d activities would be generated", activitiesGenerated) + } else { + msg = fmt.Sprintf("Completed: %d activities generated", activitiesGenerated) + } + + if err := updateJobPhase(ctx, cl, &finalJob, v1alpha1.ReindexJobSucceeded, msg); err != nil { + klog.ErrorS(err, "failed to update status to Succeeded") + } + + klog.InfoS("Reindex job completed successfully", + "activitiesGenerated", activitiesGenerated, + ) + } + + return nil +} + +// updateJobPhase updates the ReindexJob phase and message, setting CompletedAt for terminal phases. +func updateJobPhase(ctx context.Context, cl client.Client, job *v1alpha1.ReindexJob, phase v1alpha1.ReindexJobPhase, message string) error { + // Fetch latest version + var latest v1alpha1.ReindexJob + if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &latest); err != nil { + return err + } + + latest.Status.Phase = phase + latest.Status.Message = message + + // Set CompletedAt for terminal phases + if phase == v1alpha1.ReindexJobSucceeded || phase == v1alpha1.ReindexJobFailed { + now := metav1.Now() + latest.Status.CompletedAt = &now + } + + // Set StartedAt for Running phase if not already set + if phase == v1alpha1.ReindexJobRunning && latest.Status.StartedAt == nil { + now := metav1.Now() + latest.Status.StartedAt = &now + } + + // Update conditions + var conditionStatus metav1.ConditionStatus + var reason string + switch phase { + case v1alpha1.ReindexJobPending: + conditionStatus = metav1.ConditionFalse + reason = "Pending" + case v1alpha1.ReindexJobRunning: + conditionStatus = metav1.ConditionFalse + reason = "InProgress" + case v1alpha1.ReindexJobSucceeded: + conditionStatus = metav1.ConditionTrue + reason = "Succeeded" + case v1alpha1.ReindexJobFailed: + conditionStatus = metav1.ConditionFalse + reason = "Failed" + } + + meta.SetStatusCondition(&latest.Status.Conditions, metav1.Condition{ + Type: "Ready", + Status: conditionStatus, + Reason: reason, + Message: message, + ObservedGeneration: latest.Generation, + }) + + return cl.Status().Update(ctx, &latest) +} + +// updateJobProgress updates the ReindexJob progress information. +func updateJobProgress(ctx context.Context, cl client.Client, job *v1alpha1.ReindexJob, progress reindex.Progress) error { + // Fetch latest version + var latest v1alpha1.ReindexJob + if err := cl.Get(ctx, types.NamespacedName{Name: job.Name}, &latest); err != nil { + return err + } + + latest.Status.Progress = &v1alpha1.ReindexProgress{ + TotalEvents: progress.TotalEvents, + ProcessedEvents: progress.ProcessedEvents, + ActivitiesGenerated: progress.ActivitiesGenerated, + Errors: progress.Errors, + CurrentBatch: progress.CurrentBatch, + TotalBatches: progress.TotalBatches, + } + + latest.Status.Message = fmt.Sprintf("Processing: %d events processed, %d activities generated", + progress.ProcessedEvents, progress.ActivitiesGenerated) + + return cl.Status().Update(ctx, &latest) +} + +// buildNATSOptions constructs NATS connection options from configuration. +func buildNATSOptions(options *ReindexWorkerOptions) ([]nats.Option, error) { + var natsOpts []nats.Option + + // Configure TLS if enabled + if options.NATSTLSEnabled { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Load client cert/key if provided + if options.NATSTLSCertFile != "" && options.NATSTLSKeyFile != "" { + cert, err := tls.LoadX509KeyPair(options.NATSTLSCertFile, options.NATSTLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load NATS TLS client cert: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + // Load CA cert if provided + if options.NATSTLSCAFile != "" { + caCert, err := os.ReadFile(options.NATSTLSCAFile) + if err != nil { + return nil, fmt.Errorf("failed to read NATS TLS CA file: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse NATS TLS CA certificate") + } + tlsConfig.RootCAs = caCertPool + } + + natsOpts = append(natsOpts, nats.Secure(tlsConfig)) + } + + return natsOpts, nil +} diff --git a/cmd/kubectl-activity/main.go b/cmd/kubectl-activity/main.go index aab8cce0..b25d5fb0 100644 --- a/cmd/kubectl-activity/main.go +++ b/cmd/kubectl-activity/main.go @@ -1,18 +1,18 @@ -package main - -import ( - "fmt" - "os" - - "go.miloapis.com/activity/pkg/cmd" -) - -func main() { - rootCmd := cmd.NewActivityCommand(cmd.ActivityCommandOptions{}) - rootCmd.Use = "kubectl-activity" - - if err := rootCmd.Execute(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} +package main + +import ( + "fmt" + "os" + + "go.miloapis.com/activity/pkg/cmd" +) + +func main() { + rootCmd := cmd.NewActivityCommand(cmd.ActivityCommandOptions{}) + rootCmd.Use = "kubectl-activity" + + if err := rootCmd.Execute(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/config/base/controller-manager.yaml b/config/base/controller-manager.yaml index 68654e97..5b6382b9 100644 --- a/config/base/controller-manager.yaml +++ b/config/base/controller-manager.yaml @@ -1,170 +1,170 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: activity-controller-manager - namespace: activity-system ---- -# ClusterRole is auto-generated from kubebuilder annotations -# See: config/base/generated/controller-manager-rbac.yaml -# Run: task generate:rbac -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: activity-controller-manager -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: activity-controller-manager -subjects: -- kind: ServiceAccount - name: activity-controller-manager - namespace: activity-system ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-controller-manager - namespace: activity-system -spec: - replicas: 1 - selector: - matchLabels: - app: activity-controller-manager - template: - metadata: - labels: - app: activity-controller-manager - spec: - serviceAccountName: activity-controller-manager - securityContext: - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - fsGroup: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: manager - image: ghcr.io/datum-cloud/activity:latest - imagePullPolicy: IfNotPresent - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65532 - capabilities: - drop: - - ALL - ports: - - containerPort: 8080 - name: metrics - protocol: TCP - - containerPort: 8081 - name: health - protocol: TCP - command: - - /activity - - controller-manager - args: - - --kubeconfig=$(KUBECONFIG) - - --workers=$(WORKERS) - - --metrics-addr=$(METRICS_ADDR) - - --health-probe-addr=$(HEALTH_PROBE_ADDR) - - --nats-url=$(NATS_URL) - - --nats-tls-enabled=$(NATS_TLS_ENABLED) - - --nats-tls-cert-file=$(NATS_TLS_CERT_FILE) - - --nats-tls-key-file=$(NATS_TLS_KEY_FILE) - - --nats-tls-ca-file=$(NATS_TLS_CA_FILE) - - --reindex-job-namespace=$(REINDEX_JOB_NAMESPACE) - - --reindex-service-account=$(REINDEX_SERVICE_ACCOUNT) - - --reindex-memory-limit=$(REINDEX_MEMORY_LIMIT) - - --reindex-cpu-limit=$(REINDEX_CPU_LIMIT) - - --max-concurrent-reindex-jobs=$(MAX_CONCURRENT_REINDEX_JOBS) - - --activity-image=$(ACTIVITY_IMAGE) - - --reindex-job-template-configmap=$(REINDEX_JOB_TEMPLATE_CONFIGMAP) - - -v=$(LOG_LEVEL) - - --logging-format=$(LOGGING_FORMAT) - env: - - name: KUBECONFIG - value: "" - - name: WORKERS - value: "2" - - name: METRICS_ADDR - value: ":8080" - - name: HEALTH_PROBE_ADDR - value: ":8081" - - name: NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - - name: NATS_TLS_ENABLED - value: "false" - - name: NATS_TLS_CERT_FILE - value: "" - - name: NATS_TLS_KEY_FILE - value: "" - - name: NATS_TLS_CA_FILE - value: "" - - name: LOG_LEVEL - value: "2" - - name: LOGGING_FORMAT - value: "json" - - name: ACTIVITY_IMAGE - value: "PLACEHOLDER" # Replaced by kustomize to match deployed image - - name: REINDEX_JOB_NAMESPACE - value: "activity-system" - - name: REINDEX_SERVICE_ACCOUNT - value: "activity-reindex-worker" - - name: REINDEX_MEMORY_LIMIT - value: "2Gi" - - name: REINDEX_CPU_LIMIT - value: "1000m" - - name: MAX_CONCURRENT_REINDEX_JOBS - value: "1" - - name: REINDEX_JOB_TEMPLATE_CONFIGMAP - value: "" # Optional: namespace/name of ConfigMap containing job template - - name: CLICKHOUSE_ADDRESS - valueFrom: - secretKeyRef: - name: clickhouse-credentials - key: address - - name: CLICKHOUSE_DATABASE - valueFrom: - secretKeyRef: - name: clickhouse-credentials - key: database - - name: CLICKHOUSE_USERNAME - valueFrom: - secretKeyRef: - name: clickhouse-credentials - key: username - - name: CLICKHOUSE_PASSWORD - valueFrom: - secretKeyRef: - name: clickhouse-credentials - key: password - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 200m - memory: 256Mi - livenessProbe: - httpGet: - path: /healthz - port: health - scheme: HTTP - initialDelaySeconds: 15 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /readyz - port: health - scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 10 - volumeMounts: - - name: tmp - mountPath: /tmp - volumes: - - name: tmp - emptyDir: {} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: activity-controller-manager + namespace: activity-system +--- +# ClusterRole is auto-generated from kubebuilder annotations +# See: config/base/generated/controller-manager-rbac.yaml +# Run: task generate:rbac +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: activity-controller-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: activity-controller-manager +subjects: +- kind: ServiceAccount + name: activity-controller-manager + namespace: activity-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-controller-manager + namespace: activity-system +spec: + replicas: 1 + selector: + matchLabels: + app: activity-controller-manager + template: + metadata: + labels: + app: activity-controller-manager + spec: + serviceAccountName: activity-controller-manager + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: manager + image: ghcr.io/datum-cloud/activity:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: + - ALL + ports: + - containerPort: 8080 + name: metrics + protocol: TCP + - containerPort: 8081 + name: health + protocol: TCP + command: + - /activity + - controller-manager + args: + - --kubeconfig=$(KUBECONFIG) + - --workers=$(WORKERS) + - --metrics-addr=$(METRICS_ADDR) + - --health-probe-addr=$(HEALTH_PROBE_ADDR) + - --nats-url=$(NATS_URL) + - --nats-tls-enabled=$(NATS_TLS_ENABLED) + - --nats-tls-cert-file=$(NATS_TLS_CERT_FILE) + - --nats-tls-key-file=$(NATS_TLS_KEY_FILE) + - --nats-tls-ca-file=$(NATS_TLS_CA_FILE) + - --reindex-job-namespace=$(REINDEX_JOB_NAMESPACE) + - --reindex-service-account=$(REINDEX_SERVICE_ACCOUNT) + - --reindex-memory-limit=$(REINDEX_MEMORY_LIMIT) + - --reindex-cpu-limit=$(REINDEX_CPU_LIMIT) + - --max-concurrent-reindex-jobs=$(MAX_CONCURRENT_REINDEX_JOBS) + - --activity-image=$(ACTIVITY_IMAGE) + - --reindex-job-template-configmap=$(REINDEX_JOB_TEMPLATE_CONFIGMAP) + - -v=$(LOG_LEVEL) + - --logging-format=$(LOGGING_FORMAT) + env: + - name: KUBECONFIG + value: "" + - name: WORKERS + value: "2" + - name: METRICS_ADDR + value: ":8080" + - name: HEALTH_PROBE_ADDR + value: ":8081" + - name: NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + - name: NATS_TLS_ENABLED + value: "false" + - name: NATS_TLS_CERT_FILE + value: "" + - name: NATS_TLS_KEY_FILE + value: "" + - name: NATS_TLS_CA_FILE + value: "" + - name: LOG_LEVEL + value: "2" + - name: LOGGING_FORMAT + value: "json" + - name: ACTIVITY_IMAGE + value: "PLACEHOLDER" # Replaced by kustomize to match deployed image + - name: REINDEX_JOB_NAMESPACE + value: "activity-system" + - name: REINDEX_SERVICE_ACCOUNT + value: "activity-reindex-worker" + - name: REINDEX_MEMORY_LIMIT + value: "2Gi" + - name: REINDEX_CPU_LIMIT + value: "1000m" + - name: MAX_CONCURRENT_REINDEX_JOBS + value: "1" + - name: REINDEX_JOB_TEMPLATE_CONFIGMAP + value: "" # Optional: namespace/name of ConfigMap containing job template + - name: CLICKHOUSE_ADDRESS + valueFrom: + secretKeyRef: + name: clickhouse-credentials + key: address + - name: CLICKHOUSE_DATABASE + valueFrom: + secretKeyRef: + name: clickhouse-credentials + key: database + - name: CLICKHOUSE_USERNAME + valueFrom: + secretKeyRef: + name: clickhouse-credentials + key: username + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: clickhouse-credentials + key: password + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + livenessProbe: + httpGet: + path: /healthz + port: health + scheme: HTTP + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: health + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} diff --git a/config/base/deployment.yaml b/config/base/deployment.yaml index 7eac5225..a9008caf 100644 --- a/config/base/deployment.yaml +++ b/config/base/deployment.yaml @@ -1,231 +1,231 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-apiserver - namespace: activity-system -spec: - replicas: 1 - selector: - matchLabels: - app: activity-apiserver - template: - metadata: - labels: - app: activity-apiserver - spec: - serviceAccountName: activity-apiserver - securityContext: - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - fsGroup: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: apiserver - image: ghcr.io/datum-cloud/activity:latest - imagePullPolicy: IfNotPresent - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65532 - capabilities: - drop: - - ALL - ports: - - containerPort: 8443 - name: https - protocol: TCP - command: - - /activity - - serve - args: - - --secure-port=$(SECURE_PORT) - - --tls-cert-file=$(TLS_CERT_FILE) - - --tls-private-key-file=$(TLS_PRIVATE_KEY_FILE) - - --clickhouse-address=$(CLICKHOUSE_ADDRESS) - - --clickhouse-database=$(CLICKHOUSE_DATABASE) - - --clickhouse-username=$(CLICKHOUSE_USERNAME) - - --clickhouse-password=$(CLICKHOUSE_PASSWORD) - - --clickhouse-tls-enabled=$(CLICKHOUSE_TLS_ENABLED) - - --clickhouse-tls-cert-file=$(CLICKHOUSE_TLS_CERT_FILE) - - --clickhouse-tls-key-file=$(CLICKHOUSE_TLS_KEY_FILE) - - --clickhouse-tls-ca-file=$(CLICKHOUSE_TLS_CA_FILE) - - --kubeconfig=$(KUBECONFIG) - - --authentication-kubeconfig=$(AUTHENTICATION_KUBECONFIG) - - --authentication-skip-lookup=$(AUTHENTICATION_SKIP_LOOKUP) - - --authentication-tolerate-lookup-failure=$(AUTHENTICATION_TOLERATE_LOOKUP_FAILURE) - - --authorization-kubeconfig=$(AUTHORIZATION_KUBECONFIG) - - --authorization-always-allow-paths=$(AUTHORIZATION_ALWAYS_ALLOW_PATHS) - - --requestheader-client-ca-file=$(REQUESTHEADER_CLIENT_CA_FILE) - - --requestheader-username-headers=$(REQUESTHEADER_USERNAME_HEADERS) - - --requestheader-group-headers=$(REQUESTHEADER_GROUP_HEADERS) - - --requestheader-uid-headers=$(REQUESTHEADER_UID_HEADERS) - - --requestheader-extra-headers-prefix=$(REQUESTHEADER_EXTRA_HEADERS_PREFIX) - - --logging-format=$(LOGGING_FORMAT) - - --tracing-config-file=$(TRACING_CONFIG_FILE) - - --etcd-servers=$(ETCD_SERVERS) - - --etcd-prefix=$(ETCD_PREFIX) - - --etcd-cafile=$(ETCD_CAFILE) - - --etcd-certfile=$(ETCD_CERTFILE) - - --etcd-keyfile=$(ETCD_KEYFILE) - - --activities-nats-url=$(ACTIVITIES_NATS_URL) - - --activities-nats-stream=$(ACTIVITIES_NATS_STREAM) - - --activities-nats-subject-prefix=$(ACTIVITIES_NATS_SUBJECT_PREFIX) - - --activities-nats-tls-enabled=$(ACTIVITIES_NATS_TLS_ENABLED) - - --activities-nats-tls-cert-file=$(ACTIVITIES_NATS_TLS_CERT_FILE) - - --activities-nats-tls-key-file=$(ACTIVITIES_NATS_TLS_KEY_FILE) - - --activities-nats-tls-ca-file=$(ACTIVITIES_NATS_TLS_CA_FILE) - - --events-nats-url=$(EVENTS_NATS_URL) - - --events-nats-stream=$(EVENTS_NATS_STREAM) - - --events-nats-subject-prefix=$(EVENTS_NATS_SUBJECT_PREFIX) - - --events-nats-tls-enabled=$(EVENTS_NATS_TLS_ENABLED) - - --events-nats-tls-cert-file=$(EVENTS_NATS_TLS_CERT_FILE) - - --events-nats-tls-key-file=$(EVENTS_NATS_TLS_KEY_FILE) - - --events-nats-tls-ca-file=$(EVENTS_NATS_TLS_CA_FILE) - - -v=$(LOG_LEVEL) - env: - - name: SECURE_PORT - value: "8443" - - name: TLS_CERT_FILE - value: "/var/run/activity-apiserver/tls/tls.crt" - - name: TLS_PRIVATE_KEY_FILE - value: "/var/run/activity-apiserver/tls/tls.key" - - name: KUBECONFIG - value: "" - - name: AUTHENTICATION_KUBECONFIG - value: "" - - name: AUTHENTICATION_SKIP_LOOKUP - value: "false" - - name: AUTHENTICATION_TOLERATE_LOOKUP_FAILURE - value: "false" - - name: AUTHORIZATION_KUBECONFIG - value: "" - - name: AUTHORIZATION_ALWAYS_ALLOW_PATHS - value: "/healthz,/readyz,/livez" - - name: REQUESTHEADER_CLIENT_CA_FILE - value: "" - - name: REQUESTHEADER_USERNAME_HEADERS - value: "X-Remote-User" - - name: REQUESTHEADER_GROUP_HEADERS - value: "X-Remote-Group" - - name: REQUESTHEADER_UID_HEADERS - value: "X-Remote-Uid" - - name: REQUESTHEADER_EXTRA_HEADERS_PREFIX - value: "X-Remote-Extra-" - - name: LOGGING_FORMAT - value: "json" - - name: LOG_LEVEL - value: "4" - - name: TRACING_CONFIG_FILE - value: "" - - name: CLICKHOUSE_ADDRESS - value: "clickhouse.activity-system.svc.cluster.local:9000" - - name: CLICKHOUSE_DATABASE - value: "audit" - - name: CLICKHOUSE_USERNAME - value: "default" - - name: CLICKHOUSE_PASSWORD - valueFrom: - secretKeyRef: - name: clickhouse-credentials - key: password - optional: true - - name: CLICKHOUSE_TLS_ENABLED - value: "false" - - name: CLICKHOUSE_TLS_CERT_FILE - value: "" - - name: CLICKHOUSE_TLS_KEY_FILE - value: "" - - name: CLICKHOUSE_TLS_CA_FILE - value: "" - - name: ETCD_SERVERS - value: "http://etcd.activity-system.svc.cluster.local:2379" - - name: ETCD_PREFIX - value: "/registry/activity.miloapis.com" - - name: ETCD_CAFILE - value: "" - - name: ETCD_CERTFILE - value: "" - - name: ETCD_KEYFILE - value: "" - # NATS configuration for Activities Watch API (empty = Watch API disabled) - - name: ACTIVITIES_NATS_URL - value: "" - - name: ACTIVITIES_NATS_STREAM - value: "ACTIVITIES" - - name: ACTIVITIES_NATS_SUBJECT_PREFIX - value: "activities" - - name: ACTIVITIES_NATS_TLS_ENABLED - value: "false" - - name: ACTIVITIES_NATS_TLS_CERT_FILE - value: "" - - name: ACTIVITIES_NATS_TLS_KEY_FILE - value: "" - - name: ACTIVITIES_NATS_TLS_CA_FILE - value: "" - # NATS configuration for Events Watch API (empty = Watch API disabled) - - name: EVENTS_NATS_URL - value: "" - - name: EVENTS_NATS_STREAM - value: "EVENTS" - - name: EVENTS_NATS_SUBJECT_PREFIX - value: "events" - - name: EVENTS_NATS_TLS_ENABLED - value: "false" - - name: EVENTS_NATS_TLS_CERT_FILE - value: "" - - name: EVENTS_NATS_TLS_KEY_FILE - value: "" - - name: EVENTS_NATS_TLS_CA_FILE - value: "" - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - livenessProbe: - httpGet: - path: /healthz - port: https - scheme: HTTPS - initialDelaySeconds: 15 - periodSeconds: 20 - readinessProbe: - httpGet: - path: /readyz - port: https - scheme: HTTPS - initialDelaySeconds: 5 - periodSeconds: 10 - volumeMounts: - - name: tls-certs - mountPath: /var/run/activity-apiserver/tls - readOnly: true - - name: control-plane-ca - mountPath: /etc/kubernetes/pki/requestheader - readOnly: true - - name: tmp - mountPath: /tmp - volumes: - - name: tls-certs - csi: - driver: csi.cert-manager.io - readOnly: true - volumeAttributes: - csi.cert-manager.io/issuer-name: selfsigned-cluster-issuer - csi.cert-manager.io/issuer-kind: ClusterIssuer - csi.cert-manager.io/common-name: activity-apiserver.activity-system.svc - csi.cert-manager.io/dns-names: "activity-apiserver,activity-apiserver.activity-system,activity-apiserver.activity-system.svc,activity-apiserver.activity-system.svc.cluster.local" - csi.cert-manager.io/duration: "8760h" - csi.cert-manager.io/renew-before: "720h" - csi.cert-manager.io/fs-group: "65532" - - name: control-plane-ca - configMap: - name: control-plane-ca - optional: true - - name: tmp - emptyDir: {} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-apiserver + namespace: activity-system +spec: + replicas: 1 + selector: + matchLabels: + app: activity-apiserver + template: + metadata: + labels: + app: activity-apiserver + spec: + serviceAccountName: activity-apiserver + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: apiserver + image: ghcr.io/datum-cloud/activity:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: + - ALL + ports: + - containerPort: 8443 + name: https + protocol: TCP + command: + - /activity + - serve + args: + - --secure-port=$(SECURE_PORT) + - --tls-cert-file=$(TLS_CERT_FILE) + - --tls-private-key-file=$(TLS_PRIVATE_KEY_FILE) + - --clickhouse-address=$(CLICKHOUSE_ADDRESS) + - --clickhouse-database=$(CLICKHOUSE_DATABASE) + - --clickhouse-username=$(CLICKHOUSE_USERNAME) + - --clickhouse-password=$(CLICKHOUSE_PASSWORD) + - --clickhouse-tls-enabled=$(CLICKHOUSE_TLS_ENABLED) + - --clickhouse-tls-cert-file=$(CLICKHOUSE_TLS_CERT_FILE) + - --clickhouse-tls-key-file=$(CLICKHOUSE_TLS_KEY_FILE) + - --clickhouse-tls-ca-file=$(CLICKHOUSE_TLS_CA_FILE) + - --kubeconfig=$(KUBECONFIG) + - --authentication-kubeconfig=$(AUTHENTICATION_KUBECONFIG) + - --authentication-skip-lookup=$(AUTHENTICATION_SKIP_LOOKUP) + - --authentication-tolerate-lookup-failure=$(AUTHENTICATION_TOLERATE_LOOKUP_FAILURE) + - --authorization-kubeconfig=$(AUTHORIZATION_KUBECONFIG) + - --authorization-always-allow-paths=$(AUTHORIZATION_ALWAYS_ALLOW_PATHS) + - --requestheader-client-ca-file=$(REQUESTHEADER_CLIENT_CA_FILE) + - --requestheader-username-headers=$(REQUESTHEADER_USERNAME_HEADERS) + - --requestheader-group-headers=$(REQUESTHEADER_GROUP_HEADERS) + - --requestheader-uid-headers=$(REQUESTHEADER_UID_HEADERS) + - --requestheader-extra-headers-prefix=$(REQUESTHEADER_EXTRA_HEADERS_PREFIX) + - --logging-format=$(LOGGING_FORMAT) + - --tracing-config-file=$(TRACING_CONFIG_FILE) + - --etcd-servers=$(ETCD_SERVERS) + - --etcd-prefix=$(ETCD_PREFIX) + - --etcd-cafile=$(ETCD_CAFILE) + - --etcd-certfile=$(ETCD_CERTFILE) + - --etcd-keyfile=$(ETCD_KEYFILE) + - --activities-nats-url=$(ACTIVITIES_NATS_URL) + - --activities-nats-stream=$(ACTIVITIES_NATS_STREAM) + - --activities-nats-subject-prefix=$(ACTIVITIES_NATS_SUBJECT_PREFIX) + - --activities-nats-tls-enabled=$(ACTIVITIES_NATS_TLS_ENABLED) + - --activities-nats-tls-cert-file=$(ACTIVITIES_NATS_TLS_CERT_FILE) + - --activities-nats-tls-key-file=$(ACTIVITIES_NATS_TLS_KEY_FILE) + - --activities-nats-tls-ca-file=$(ACTIVITIES_NATS_TLS_CA_FILE) + - --events-nats-url=$(EVENTS_NATS_URL) + - --events-nats-stream=$(EVENTS_NATS_STREAM) + - --events-nats-subject-prefix=$(EVENTS_NATS_SUBJECT_PREFIX) + - --events-nats-tls-enabled=$(EVENTS_NATS_TLS_ENABLED) + - --events-nats-tls-cert-file=$(EVENTS_NATS_TLS_CERT_FILE) + - --events-nats-tls-key-file=$(EVENTS_NATS_TLS_KEY_FILE) + - --events-nats-tls-ca-file=$(EVENTS_NATS_TLS_CA_FILE) + - -v=$(LOG_LEVEL) + env: + - name: SECURE_PORT + value: "8443" + - name: TLS_CERT_FILE + value: "/var/run/activity-apiserver/tls/tls.crt" + - name: TLS_PRIVATE_KEY_FILE + value: "/var/run/activity-apiserver/tls/tls.key" + - name: KUBECONFIG + value: "" + - name: AUTHENTICATION_KUBECONFIG + value: "" + - name: AUTHENTICATION_SKIP_LOOKUP + value: "false" + - name: AUTHENTICATION_TOLERATE_LOOKUP_FAILURE + value: "false" + - name: AUTHORIZATION_KUBECONFIG + value: "" + - name: AUTHORIZATION_ALWAYS_ALLOW_PATHS + value: "/healthz,/readyz,/livez" + - name: REQUESTHEADER_CLIENT_CA_FILE + value: "" + - name: REQUESTHEADER_USERNAME_HEADERS + value: "X-Remote-User" + - name: REQUESTHEADER_GROUP_HEADERS + value: "X-Remote-Group" + - name: REQUESTHEADER_UID_HEADERS + value: "X-Remote-Uid" + - name: REQUESTHEADER_EXTRA_HEADERS_PREFIX + value: "X-Remote-Extra-" + - name: LOGGING_FORMAT + value: "json" + - name: LOG_LEVEL + value: "4" + - name: TRACING_CONFIG_FILE + value: "" + - name: CLICKHOUSE_ADDRESS + value: "clickhouse.activity-system.svc.cluster.local:9000" + - name: CLICKHOUSE_DATABASE + value: "audit" + - name: CLICKHOUSE_USERNAME + value: "default" + - name: CLICKHOUSE_PASSWORD + valueFrom: + secretKeyRef: + name: clickhouse-credentials + key: password + optional: true + - name: CLICKHOUSE_TLS_ENABLED + value: "false" + - name: CLICKHOUSE_TLS_CERT_FILE + value: "" + - name: CLICKHOUSE_TLS_KEY_FILE + value: "" + - name: CLICKHOUSE_TLS_CA_FILE + value: "" + - name: ETCD_SERVERS + value: "http://etcd.activity-system.svc.cluster.local:2379" + - name: ETCD_PREFIX + value: "/registry/activity.miloapis.com" + - name: ETCD_CAFILE + value: "" + - name: ETCD_CERTFILE + value: "" + - name: ETCD_KEYFILE + value: "" + # NATS configuration for Activities Watch API (empty = Watch API disabled) + - name: ACTIVITIES_NATS_URL + value: "" + - name: ACTIVITIES_NATS_STREAM + value: "ACTIVITIES" + - name: ACTIVITIES_NATS_SUBJECT_PREFIX + value: "activities" + - name: ACTIVITIES_NATS_TLS_ENABLED + value: "false" + - name: ACTIVITIES_NATS_TLS_CERT_FILE + value: "" + - name: ACTIVITIES_NATS_TLS_KEY_FILE + value: "" + - name: ACTIVITIES_NATS_TLS_CA_FILE + value: "" + # NATS configuration for Events Watch API (empty = Watch API disabled) + - name: EVENTS_NATS_URL + value: "" + - name: EVENTS_NATS_STREAM + value: "EVENTS" + - name: EVENTS_NATS_SUBJECT_PREFIX + value: "events" + - name: EVENTS_NATS_TLS_ENABLED + value: "false" + - name: EVENTS_NATS_TLS_CERT_FILE + value: "" + - name: EVENTS_NATS_TLS_KEY_FILE + value: "" + - name: EVENTS_NATS_TLS_CA_FILE + value: "" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /healthz + port: https + scheme: HTTPS + initialDelaySeconds: 15 + periodSeconds: 20 + readinessProbe: + httpGet: + path: /readyz + port: https + scheme: HTTPS + initialDelaySeconds: 5 + periodSeconds: 10 + volumeMounts: + - name: tls-certs + mountPath: /var/run/activity-apiserver/tls + readOnly: true + - name: control-plane-ca + mountPath: /etc/kubernetes/pki/requestheader + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: tls-certs + csi: + driver: csi.cert-manager.io + readOnly: true + volumeAttributes: + csi.cert-manager.io/issuer-name: selfsigned-cluster-issuer + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/common-name: activity-apiserver.activity-system.svc + csi.cert-manager.io/dns-names: "activity-apiserver,activity-apiserver.activity-system,activity-apiserver.activity-system.svc,activity-apiserver.activity-system.svc.cluster.local" + csi.cert-manager.io/duration: "8760h" + csi.cert-manager.io/renew-before: "720h" + csi.cert-manager.io/fs-group: "65532" + - name: control-plane-ca + configMap: + name: control-plane-ca + optional: true + - name: tmp + emptyDir: {} diff --git a/config/base/kustomization.yaml b/config/base/kustomization.yaml index 4dec6ec0..23763b6b 100644 --- a/config/base/kustomization.yaml +++ b/config/base/kustomization.yaml @@ -1,47 +1,47 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: activity-system - -resources: - - serviceaccount.yaml - - deployment.yaml - - processor.yaml # Activity processor deployment + RBAC - - controller-manager.yaml # Controller manager deployment + ServiceAccount + ClusterRoleBinding - - generated/controller-manager-rbac.yaml # Auto-generated ClusterRole from kubebuilder annotations - - service.yaml - - secret.yaml - - rbac-auth-reader.yaml - - rbac-cluster.yaml - - rbac/reindex-worker-sa.yaml - - rbac/reindex-worker-role.yaml - - rbac/reindex-worker-rolebinding.yaml - -labels: - - includeSelectors: true - includeTemplates: true - pairs: - app.kubernetes.io/name: activity - app.kubernetes.io/instance: activity-apiserver - app.kubernetes.io/component: apiserver - app.kubernetes.io/part-of: activity.miloapis.com - app.kubernetes.io/managed-by: kustomize - -# Images (will be replaced by overlays for different environments) -images: - - name: ghcr.io/datum-cloud/activity - newTag: latest - -# JSON patches to ensure rbac-auth-reader stays in kube-system -# These are applied AFTER all other transformations -patches: - - target: - kind: RoleBinding - name: activity-apiserver-auth-reader - patch: |- - - op: replace - path: /metadata/namespace - value: kube-system - - op: replace - path: /subjects/0/namespace - value: activity-system +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: activity-system + +resources: + - serviceaccount.yaml + - deployment.yaml + - processor.yaml # Activity processor deployment + RBAC + - controller-manager.yaml # Controller manager deployment + ServiceAccount + ClusterRoleBinding + - generated/controller-manager-rbac.yaml # Auto-generated ClusterRole from kubebuilder annotations + - service.yaml + - secret.yaml + - rbac-auth-reader.yaml + - rbac-cluster.yaml + - rbac/reindex-worker-sa.yaml + - rbac/reindex-worker-role.yaml + - rbac/reindex-worker-rolebinding.yaml + +labels: + - includeSelectors: true + includeTemplates: true + pairs: + app.kubernetes.io/name: activity + app.kubernetes.io/instance: activity-apiserver + app.kubernetes.io/component: apiserver + app.kubernetes.io/part-of: activity.miloapis.com + app.kubernetes.io/managed-by: kustomize + +# Images (will be replaced by overlays for different environments) +images: + - name: ghcr.io/datum-cloud/activity + newTag: latest + +# JSON patches to ensure rbac-auth-reader stays in kube-system +# These are applied AFTER all other transformations +patches: + - target: + kind: RoleBinding + name: activity-apiserver-auth-reader + patch: |- + - op: replace + path: /metadata/namespace + value: kube-system + - op: replace + path: /subjects/0/namespace + value: activity-system diff --git a/config/base/processor.yaml b/config/base/processor.yaml index cc084843..61f827fb 100644 --- a/config/base/processor.yaml +++ b/config/base/processor.yaml @@ -1,174 +1,174 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: activity-processor - namespace: activity-system ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: activity-processor -rules: -# Read ActivityPolicies to know which rules to apply -- apiGroups: - - activity.miloapis.com - resources: - - activitypolicies - verbs: - - get - - list - - watch ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: activity-processor -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: activity-processor -subjects: -- kind: ServiceAccount - name: activity-processor - namespace: activity-system ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-processor - namespace: activity-system -spec: - replicas: 1 - selector: - matchLabels: - app: activity-processor - template: - metadata: - labels: - app: activity-processor - spec: - serviceAccountName: activity-processor - securityContext: - runAsNonRoot: true - runAsUser: 65532 - runAsGroup: 65532 - fsGroup: 65532 - seccompProfile: - type: RuntimeDefault - containers: - - name: processor - image: ghcr.io/datum-cloud/activity:latest - imagePullPolicy: IfNotPresent - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - runAsNonRoot: true - runAsUser: 65532 - capabilities: - drop: - - ALL - ports: - - containerPort: 8081 - name: health - protocol: TCP - command: - - /activity - - processor - args: - - --kubeconfig=$(ACTIVITY_KUBECONFIG) - - --nats-url=$(NATS_URL) - - --nats-stream=$(NATS_STREAM) - - --consumer-name=$(CONSUMER_NAME) - - --output-stream=$(OUTPUT_STREAM) - - --output-subject-prefix=$(OUTPUT_SUBJECT_PREFIX) - - --nats-tls-enabled=$(NATS_TLS_ENABLED) - - --nats-tls-cert-file=$(NATS_TLS_CERT_FILE) - - --nats-tls-key-file=$(NATS_TLS_KEY_FILE) - - --nats-tls-ca-file=$(NATS_TLS_CA_FILE) - - --workers=$(WORKERS) - - --batch-size=$(BATCH_SIZE) - - --health-probe-addr=$(HEALTH_PROBE_ADDR) - - -v=$(LOG_LEVEL) - - --logging-format=$(LOGGING_FORMAT) - env: - - name: ACTIVITY_KUBECONFIG - value: "" - - name: NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - - name: NATS_STREAM - value: "AUDIT_EVENTS" - - name: CONSUMER_NAME - value: "activity-processor" - - name: OUTPUT_STREAM - value: "ACTIVITIES" - - name: OUTPUT_SUBJECT_PREFIX - value: "activities" - - name: NATS_TLS_ENABLED - value: "false" - - name: NATS_TLS_CERT_FILE - value: "" - - name: NATS_TLS_KEY_FILE - value: "" - - name: NATS_TLS_CA_FILE - value: "" - - name: WORKERS - value: "4" - - name: BATCH_SIZE - value: "100" - - name: HEALTH_PROBE_ADDR - value: ":8081" - - name: LOG_LEVEL - value: "2" - - name: LOGGING_FORMAT - value: "json" - resources: - requests: - cpu: 100m - memory: 128Mi - limits: - cpu: 500m - memory: 512Mi - livenessProbe: - httpGet: - path: /healthz - port: health - scheme: HTTP - initialDelaySeconds: 15 - periodSeconds: 20 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /readyz - port: health - scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 3 - volumeMounts: - - name: tmp - mountPath: /tmp - volumes: - - name: tmp - emptyDir: {} ---- -# Service for metrics scraping by Prometheus -apiVersion: v1 -kind: Service -metadata: - name: activity-processor - namespace: activity-system - labels: - app: activity-processor - app.kubernetes.io/name: activity-processor - app.kubernetes.io/component: processor -spec: - ports: - - name: health - port: 8081 - protocol: TCP - targetPort: health - selector: - app: activity-processor - type: ClusterIP +apiVersion: v1 +kind: ServiceAccount +metadata: + name: activity-processor + namespace: activity-system +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: activity-processor +rules: +# Read ActivityPolicies to know which rules to apply +- apiGroups: + - activity.miloapis.com + resources: + - activitypolicies + verbs: + - get + - list + - watch +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: activity-processor +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: activity-processor +subjects: +- kind: ServiceAccount + name: activity-processor + namespace: activity-system +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-processor + namespace: activity-system +spec: + replicas: 1 + selector: + matchLabels: + app: activity-processor + template: + metadata: + labels: + app: activity-processor + spec: + serviceAccountName: activity-processor + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: processor + image: ghcr.io/datum-cloud/activity:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + capabilities: + drop: + - ALL + ports: + - containerPort: 8081 + name: health + protocol: TCP + command: + - /activity + - processor + args: + - --kubeconfig=$(ACTIVITY_KUBECONFIG) + - --nats-url=$(NATS_URL) + - --nats-stream=$(NATS_STREAM) + - --consumer-name=$(CONSUMER_NAME) + - --output-stream=$(OUTPUT_STREAM) + - --output-subject-prefix=$(OUTPUT_SUBJECT_PREFIX) + - --nats-tls-enabled=$(NATS_TLS_ENABLED) + - --nats-tls-cert-file=$(NATS_TLS_CERT_FILE) + - --nats-tls-key-file=$(NATS_TLS_KEY_FILE) + - --nats-tls-ca-file=$(NATS_TLS_CA_FILE) + - --workers=$(WORKERS) + - --batch-size=$(BATCH_SIZE) + - --health-probe-addr=$(HEALTH_PROBE_ADDR) + - -v=$(LOG_LEVEL) + - --logging-format=$(LOGGING_FORMAT) + env: + - name: ACTIVITY_KUBECONFIG + value: "" + - name: NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + - name: NATS_STREAM + value: "AUDIT_EVENTS" + - name: CONSUMER_NAME + value: "activity-processor" + - name: OUTPUT_STREAM + value: "ACTIVITIES" + - name: OUTPUT_SUBJECT_PREFIX + value: "activities" + - name: NATS_TLS_ENABLED + value: "false" + - name: NATS_TLS_CERT_FILE + value: "" + - name: NATS_TLS_KEY_FILE + value: "" + - name: NATS_TLS_CA_FILE + value: "" + - name: WORKERS + value: "4" + - name: BATCH_SIZE + value: "100" + - name: HEALTH_PROBE_ADDR + value: ":8081" + - name: LOG_LEVEL + value: "2" + - name: LOGGING_FORMAT + value: "json" + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + livenessProbe: + httpGet: + path: /healthz + port: health + scheme: HTTP + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: health + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + volumeMounts: + - name: tmp + mountPath: /tmp + volumes: + - name: tmp + emptyDir: {} +--- +# Service for metrics scraping by Prometheus +apiVersion: v1 +kind: Service +metadata: + name: activity-processor + namespace: activity-system + labels: + app: activity-processor + app.kubernetes.io/name: activity-processor + app.kubernetes.io/component: processor +spec: + ports: + - name: health + port: 8081 + protocol: TCP + targetPort: health + selector: + app: activity-processor + type: ClusterIP diff --git a/config/components/clickhouse-migrations/configmap.yaml b/config/components/clickhouse-migrations/configmap.yaml index ae60554b..cc4db4de 100644 --- a/config/components/clickhouse-migrations/configmap.yaml +++ b/config/components/clickhouse-migrations/configmap.yaml @@ -1,1717 +1,1717 @@ -# AUTO-GENERATED - DO NOT EDIT MANUALLY -# Generated from migrations/ directory (source of truth) -# To regenerate: task migrations:generate OR ./hack/generate-migrations-configmap.sh -# -# To add a new migration: -# 1. Create file in migrations/ (e.g., migrations/002_add_field.sql) -# 2. Run: task migrations:generate -# 3. Update job.yaml to include the new migration in volumes -# 4. Deploy: task dev:deploy - -apiVersion: v1 -kind: ConfigMap -metadata: - name: clickhouse-migrations - namespace: activity-system - labels: - app: clickhouse-migrations - app.kubernetes.io/component: database -data: - # Migration runner script - migrate.sh: | - #!/bin/bash - set -euo pipefail - - # ClickHouse Migration Runner - # This script applies versioned SQL migrations to a ClickHouse database - # It tracks applied migrations in the audit.schema_migrations table - - # Configuration from environment variables - CLICKHOUSE_HOST="${CLICKHOUSE_HOST:-clickhouse}" - CLICKHOUSE_PORT="${CLICKHOUSE_PORT:-9000}" - CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" - CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" - CLICKHOUSE_DATABASE="${CLICKHOUSE_DATABASE:-audit}" - MIGRATIONS_DIR="${MIGRATIONS_DIR:-/migrations}" - CLICKHOUSE_SECURE="${CLICKHOUSE_SECURE:-false}" - CLICKHOUSE_CLIENT_EXTRA_ARGS="${CLICKHOUSE_CLIENT_EXTRA_ARGS:-}" - - # Colors for output - RED='\033[0;31m' - GREEN='\033[0;32m' - YELLOW='\033[1;33m' - BLUE='\033[0;34m' - NC='\033[0m' # No Color - - log_info() { - echo -e "${BLUE}[INFO]${NC} $1" - } - - log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" - } - - log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" - } - - log_error() { - echo -e "${RED}[ERROR]${NC} $1" - } - - # Build clickhouse-client command with authentication - clickhouse_cmd() { - local query="$1" - local cmd="clickhouse-client ${CLICKHOUSE_CLIENT_EXTRA_ARGS} --host=${CLICKHOUSE_HOST} --port=${CLICKHOUSE_PORT} --user=${CLICKHOUSE_USER}" - - if [ -n "${CLICKHOUSE_PASSWORD}" ]; then - cmd="${cmd} --password=${CLICKHOUSE_PASSWORD}" - fi - - echo "${query}" | ${cmd} - } - - # Wait for ClickHouse to be ready - wait_for_clickhouse() { - log_info "Waiting for ClickHouse to be ready at ${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}..." - - local max_attempts=30 - local attempt=1 - - while [ $attempt -le $max_attempts ]; do - if clickhouse_cmd "SELECT 1" &>/dev/null; then - log_success "ClickHouse is ready!" - return 0 - fi - - log_info "Attempt $attempt/$max_attempts: ClickHouse not ready yet, waiting..." - sleep 2 - attempt=$((attempt + 1)) - done - - log_error "ClickHouse did not become ready within the timeout period" - return 1 - } - - # Wait for all replicas in the cluster to be healthy and ready - # This function will wait indefinitely until all replicas are online and healthy - wait_for_cluster_ready() { - local expected_replicas="${EXPECTED_REPLICAS:-3}" - log_info "Waiting for all ${expected_replicas} replicas in the 'activity' cluster to be ready..." - log_info "This will wait indefinitely until the cluster is healthy." - local attempt=1 - - while true; do - # Check if the 'activity' cluster exists and has the expected number of replicas - local cluster_exists=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity'" 2>/dev/null || echo "0") - - if [ "$cluster_exists" -eq 0 ]; then - log_info "Attempt $attempt: 'activity' cluster not yet registered in system.clusters, waiting..." - sleep 5 - attempt=$((attempt + 1)) - continue - fi - - # Get total number of replicas in the cluster - local total_replicas=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity'" 2>/dev/null || echo "0") - - # Get number of healthy replicas (errors_count=0 means no connection errors) - local healthy_replicas=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity' AND errors_count=0" 2>/dev/null || echo "0") - - # Check if we have the expected number of replicas and all are healthy - if [ "$total_replicas" -eq "$expected_replicas" ] && [ "$healthy_replicas" -eq "$expected_replicas" ]; then - log_success "All $expected_replicas replicas are registered and healthy!" - - # Additional check: verify Keeper connectivity for distributed DDL - log_info "Verifying ClickHouse Keeper connectivity for distributed DDL..." - if clickhouse_cmd "SELECT count() FROM system.zookeeper WHERE path='/clickhouse/activity'" &>/dev/null; then - log_success "ClickHouse Keeper is accessible and cluster coordination is ready!" - - # Final verification: display cluster topology - log_info "Cluster topology:" - clickhouse_cmd " - SELECT - cluster, - shard_num, - replica_num, - host_name, - port, - errors_count - FROM system.clusters - WHERE cluster = 'activity' - ORDER BY shard_num, replica_num - FORMAT PrettyCompact - " || true - - return 0 - else - log_info "Attempt $attempt: Keeper connectivity not ready yet, waiting..." - fi - else - log_info "Attempt $attempt: $healthy_replicas/$total_replicas healthy replicas (expected: $expected_replicas), waiting..." - fi - - sleep 5 - attempt=$((attempt + 1)) - done - } - - # Initialize the schema_migrations table if it doesn't exist - init_migrations_table() { - log_info "Verifying schema_migrations table..." - - # Note: Both database and schema_migrations table creation are handled by the - # first migration (001_initial_schema.sql). This function simply verifies - # the table exists before we try to query it for already-applied migrations. - # - # We don't create it here because: - # 1. The table should be created with the Replicated database engine for HA - # 2. All schema changes should go through the migration system for consistency - # 3. The first migration will create both the database and this tracking table - - # Check if the table exists (will be created by first migration if not) - local table_exists=$(clickhouse_cmd " - SELECT count() - FROM system.tables - WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'schema_migrations' - " 2>/dev/null || echo "0") - - if [ "${table_exists}" -eq 0 ]; then - log_info "Schema migrations table does not exist yet - will be created by first migration" - else - log_success "Schema migrations table exists and is ready" - fi - } - - # Calculate checksum of a file - calculate_checksum() { - local file="$1" - sha256sum "${file}" | awk '{print $1}' - } - - # Check if a migration has already been applied - is_migration_applied() { - local version="$1" - - # If the schema_migrations table doesn't exist yet, no migrations have been applied - # This handles the case for the very first migration which creates the table - local result=$(clickhouse_cmd " - SELECT count(*) - FROM ${CLICKHOUSE_DATABASE}.schema_migrations - WHERE version = ${version} - " 2>/dev/null || echo "0") - - [ "${result}" -gt 0 ] - } - - # Record a migration as applied - record_migration() { - local version="$1" - local name="$2" - local checksum="$3" - - clickhouse_cmd " - INSERT INTO ${CLICKHOUSE_DATABASE}.schema_migrations - (version, name, checksum) - VALUES (${version}, '${name}', '${checksum}') - " - } - - # Apply a single migration file - apply_migration() { - local migration_file="$1" - local filename=$(basename "${migration_file}") - - # Extract version and name from filename (e.g., 001_initial_schema.sql) - if [[ ! "${filename}" =~ ^([0-9]{3})_(.+)\.sql$ ]]; then - log_warning "Skipping ${filename}: doesn't match naming convention {version}_{name}.sql" - return 0 - fi - - local version="${BASH_REMATCH[1]}" - local name="${BASH_REMATCH[2]}" - local version_num=$((10#${version})) # Convert to decimal, removing leading zeros - local checksum=$(calculate_checksum "${migration_file}") - - # Check if already applied - if is_migration_applied "${version_num}"; then - log_info "Migration ${version}_${name} already applied, skipping" - return 0 - fi - - log_info "Applying migration ${version}_${name}..." - - # Read and execute the migration file - # We use --multiquery to allow multiple statements in one file - local cmd="clickhouse-client ${CLICKHOUSE_CLIENT_EXTRA_ARGS} --host=${CLICKHOUSE_HOST} --port=${CLICKHOUSE_PORT} --user=${CLICKHOUSE_USER}" - - if [ -n "${CLICKHOUSE_PASSWORD}" ]; then - cmd="${cmd} --password=${CLICKHOUSE_PASSWORD}" - fi - - cmd="${cmd} --multiquery" - - if cat "${migration_file}" | ${cmd}; then - # Record the migration as applied - record_migration "${version_num}" "${name}" "${checksum}" - log_success "Migration ${version}_${name} applied successfully" - return 0 - else - log_error "Failed to apply migration ${version}_${name}" - return 1 - fi - } - - # Apply all pending migrations - apply_migrations() { - log_info "Looking for migration files in ${MIGRATIONS_DIR}..." - - if [ ! -d "${MIGRATIONS_DIR}" ]; then - log_error "Migrations directory ${MIGRATIONS_DIR} not found" - return 1 - fi - - # Find all .sql files and sort them by version number - # Note: ConfigMaps in Kubernetes mount files as symlinks, so we don't use -type f - local migration_files=$(find "${MIGRATIONS_DIR}" -maxdepth 1 -name "*.sql" | sort) - - if [ -z "${migration_files}" ]; then - log_warning "No migration files found in ${MIGRATIONS_DIR}" - return 0 - fi - - local migrations_count=0 - local applied_count=0 - - while IFS= read -r migration_file; do - migrations_count=$((migrations_count + 1)) - if apply_migration "${migration_file}"; then - applied_count=$((applied_count + 1)) - else - log_error "Migration failed, stopping" - return 1 - fi - done <<< "${migration_files}" - - log_success "Migrations complete: ${applied_count} applied out of ${migrations_count} total" - - # Show current migration status - show_migration_status - } - - # Show current migration status - show_migration_status() { - log_info "Current migration status:" - clickhouse_cmd " - SELECT - version, - name, - applied_at, - substring(checksum, 1, 12) as checksum_short - FROM ${CLICKHOUSE_DATABASE}.schema_migrations - ORDER BY version - FORMAT PrettyCompact - " || log_warning "Could not fetch migration status" - } - - # Verify schema matches expected state - verify_schema() { - log_info "Verifying schema..." - - # Check if audit.audit_logs table exists (renamed from events in migration 003) - local audit_logs_table_exists=$(clickhouse_cmd " - SELECT count() - FROM system.tables - WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'audit_logs' - ") - - if [ "${audit_logs_table_exists}" -eq 0 ]; then - log_error "Table ${CLICKHOUSE_DATABASE}.audit_logs does not exist!" - return 1 - fi - - # Check if audit.k8s_events table exists (created in migration 003) - local k8s_events_table_exists=$(clickhouse_cmd " - SELECT count() - FROM system.tables - WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'k8s_events' - ") - - if [ "${k8s_events_table_exists}" -eq 0 ]; then - log_error "Table ${CLICKHOUSE_DATABASE}.k8s_events does not exist!" - return 1 - fi - - log_success "Schema verification passed" - - # Show table structure - log_info "audit_logs table structure:" - clickhouse_cmd " - DESCRIBE TABLE ${CLICKHOUSE_DATABASE}.audit_logs - FORMAT PrettyCompact - " || true - - log_info "k8s_events table structure:" - clickhouse_cmd " - DESCRIBE TABLE ${CLICKHOUSE_DATABASE}.k8s_events - FORMAT PrettyCompact - " || true - } - - # Main execution - main() { - log_info "ClickHouse Migration Runner Starting..." - log_info "Target: ${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}" - log_info "Database: ${CLICKHOUSE_DATABASE}" - log_info "Migrations Directory: ${MIGRATIONS_DIR}" - echo "" - - log_info "IMPORTANT: This migration script should only run against a single replica." - log_info "The Replicated database engine automatically propagates DDL changes to all replicas." - echo "" - - # Wait for ClickHouse to be ready - if ! wait_for_clickhouse; then - log_error "Failed to connect to ClickHouse" - exit 1 - fi - - # Display which replica we're connected to - log_info "Connected to replica:" - clickhouse_cmd "SELECT hostName() as host, getMacro('replica') as replica_name" || true - - echo "" - - # Wait for all cluster replicas to be healthy - if ! wait_for_cluster_ready; then - log_error "Cluster is not fully healthy" - exit 1 - fi - - echo "" - - # Verify migrations tracking (table will be created by first migration) - init_migrations_table - - echo "" - - # Apply all pending migrations - if ! apply_migrations; then - log_error "Migration process failed" - exit 1 - fi - - echo "" - - # Verify schema - if ! verify_schema; then - log_error "Schema verification failed" - exit 1 - fi - - echo "" - log_success "All migrations completed successfully!" - } - - # Run main function - main "$@" - - 001_initial_schema.sql: | - -- Migration: 001_initial_schema - -- Description: High-volume multi-tenant audit events table with HA replication - -- and projections for platform-wide querying and user-specific querying. - -- Author: Scot Wells - -- Date: 2025-12-11 - -- Updated: 2026-01-15 - Added HA replication support with ReplicatedReplacingMergeTree - -- Updated: 2026-01-16 - Use Replicated database engine for automatic DDL replication - - -- ============================================================================ - -- Step 1: Create Replicated Database - -- ============================================================================ - -- The Replicated database engine automatically replicates all DDL operations - -- across all replicas in the cluster. This ensures schema consistency without - -- requiring ON CLUSTER clauses. - -- - -- UUID ensures the database has the same identifier on all replicas - -- Path: /clickhouse/activity/databases/audit in ClickHouse Keeper - -- Macros: {shard} and {replica} are automatically substituted by ClickHouse - CREATE DATABASE IF NOT EXISTS audit ON CLUSTER 'activity' - ENGINE = Replicated('/clickhouse/activity/databases/audit', '{shard}', '{replica}'); - - -- ============================================================================ - -- Step 2: Create Schema Migrations Tracking Table - -- ============================================================================ - -- This table tracks which migrations have been applied to prevent re-running - -- them. Each migration records its version, name, application timestamp, and - -- checksum for integrity verification. - CREATE TABLE IF NOT EXISTS audit.schema_migrations - ( - version UInt32, - name String, - applied_at DateTime64(3) DEFAULT now64(3), - checksum String - ) ENGINE = ReplicatedReplacingMergeTree() - ORDER BY version - SETTINGS - -- No special storage policy needed for this small metadata table - storage_policy = 'default'; - - -- ============================================================================ - -- Step 3: Create Events Table - -- ============================================================================ - -- Replicated database automatically replicates table DDL - no need for ON CLUSTER - CREATE TABLE IF NOT EXISTS audit.events - ( - -- Raw audit event JSON - event_json String CODEC(ZSTD(3)), - - -- Core timestamp (always queried) - -- Uses requestReceivedTimestamp which represents when the API server received the request. - timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'requestReceivedTimestamp')), - now64(3) - ), - - -- Scope annotations (multi-tenant scoping) - scope_type LowCardinality(String) MATERIALIZED - coalesce( - JSONExtractString(event_json, 'annotations', 'platform.miloapis.com/scope.type'), - '' - ), - - scope_name String MATERIALIZED - coalesce( - JSONExtractString(event_json, 'annotations', 'platform.miloapis.com/scope.name'), - '' - ), - - -- User identity - user String MATERIALIZED - coalesce( - JSONExtractString(event_json, 'user', 'username'), - '' - ), - - user_uid String MATERIALIZED - coalesce( - JSONExtractString(event_json, 'user', 'uid'), - '' - ), - - -- Request identity - audit_id UUID MATERIALIZED - toUUIDOrZero(coalesce(JSONExtractString(event_json, 'auditID'), '')), - - -- Common filters - verb LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'verb'), ''), - - api_group LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'objectRef', 'apiGroup'), ''), - - resource LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'objectRef', 'resource'), ''), - - namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'objectRef', 'namespace'), ''), - - resource_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'objectRef', 'name'), ''), - - status_code UInt16 MATERIALIZED - toUInt16OrZero(JSONExtractString(event_json, 'responseStatus', 'code')), - - -- ======================================================================== - -- Skip Indexes: Optimized for different query patterns - -- ======================================================================== - - -- Timestamp minmax index for time range queries - INDEX idx_timestamp_minmax timestamp TYPE minmax GRANULARITY 4, - - -- Bloom filters with GRANULARITY 1 for high precision (critical filters) - INDEX idx_verb_set verb TYPE set(10) GRANULARITY 4, - INDEX idx_resource_bloom resource TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX bf_api_resource (api_group, resource) TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_verb_resource_bloom (verb, resource) TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_user_bloom user TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_user_uid_bloom user_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Set indexes for low-cardinality columns - INDEX idx_status_code_set status_code TYPE set(100) GRANULARITY 4, - -- Minmax index for status_code range queries - INDEX idx_status_code_minmax status_code TYPE minmax GRANULARITY 4, - ) - -- ================================================================== - -- TABLE ENGINE CONFIGURATION (High Availability) - -- ================================================================== - -- ReplicatedReplacingMergeTree provides: - -- - Deduplication based on ORDER BY key during merges - -- - Eventual consistency with quorum writes (configured via settings) - -- - Data replication to other database replicas - -- - -- Replication Behavior: - -- - INSERT on any replica replicates to all replicas via Keeper (database-level) - -- - Quorum writes ensure 2/3 replicas acknowledge before success - -- - Deduplication happens during background merges - ENGINE = ReplicatedReplacingMergeTree - PARTITION BY toYYYYMMDD(timestamp) - -- Primary key optimized for tenant-scoped queries with hour bucketing - -- Hour bucketing provides data locality while timestamp ensures strict chronological order - -- Timestamp as second key ensures events are always returned in time order for audit compliance - -- Deduplication occurs on the full ORDER BY key during merges - ORDER BY (toStartOfHour(timestamp), timestamp, scope_type, scope_name, user, audit_id) - PRIMARY KEY (toStartOfHour(timestamp), timestamp, scope_type, scope_name, user, audit_id) - - -- Move parts to cold S3-backed volume after 90 days - TTL timestamp + INTERVAL 90 DAY TO VOLUME 'cold' - - SETTINGS - storage_policy = 'hot_cold', - ttl_only_drop_parts = 1, - deduplicate_merge_projection_mode = 'rebuild'; - - -- ============================================================================ - -- Step 4: Add Platform Query Projection - -- ============================================================================ - -- This projection is optimized for platform-wide queries that filter by - -- timestamp, api_group, and resource (common for cross-tenant analytics). - -- - -- Sort order: (toStartOfHour(timestamp), timestamp, api_group, resource, audit_id) - -- Use cases: - -- - "All events for 'apps' API group and 'deployments' resource in last 24 hours" - -- - "All events for core API 'pods' resource" - -- - Platform-wide verb/resource filtering - -- - -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. - -- Timestamp as second key ensures events are always returned in time order for audit compliance. - - ALTER TABLE audit.events - ADD PROJECTION platform_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, api_group, resource, audit_id) - ); - - -- ============================================================================ - -- Step 5: Add User Query Projection - -- ============================================================================ - -- This projection is optimized for username-based queries within time ranges. - -- - -- Sort order: (toStartOfHour(timestamp), timestamp, user, api_group, resource, audit_id) - -- Use cases: - -- - "What did alice@example.com do in the last 24 hours?" - -- - "All events by system:serviceaccount:kube-system:default" - -- - Platform admin filtering by username in CEL expressions - -- - -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. - -- Timestamp as second key ensures events are always returned in time order for audit compliance. - -- ClickHouse automatically chooses the best projection for each query based - -- on the WHERE clause filters. - - ALTER TABLE audit.events - ADD PROJECTION user_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, user, api_group, resource, audit_id) - ); - - -- ============================================================================ - -- Step 6: Add User UID Query Projection - -- ============================================================================ - -- This projection is optimized for user-scoped queries by UID. - -- - -- Sort order: (toStartOfHour(timestamp), timestamp, user_uid, api_group, resource, audit_id) - -- Use cases: - -- - User-scoped queries: "Show all activity by user with UID abc-123" - -- - Cross-organization user activity tracking - -- - User-specific audit trail regardless of username changes - -- - -- This projection is used when scope.type == "user" to filter by user_uid - -- instead of scope_name, enabling queries for a user's activity across all - -- organizations and projects on the platform. - -- - -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. - -- Timestamp as second key ensures events are always returned in time order for audit compliance. - - ALTER TABLE audit.events - ADD PROJECTION user_uid_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, user_uid, api_group, resource, audit_id) - ); - - 002_activities_table.sql: | - -- Migration: 002_activities_table - -- Description: Activities table for storing translated activity records - -- Author: Activity System - -- Date: 2026-02-02 - - -- ============================================================================ - -- Activities Table - -- ============================================================================ - -- Stores translated activity records generated by the Activity Processor. - -- These are human-readable summaries of audit logs and Kubernetes events, - -- optimized for time-range queries and multi-tenant isolation. - - CREATE TABLE IF NOT EXISTS audit.activities - ( - -- Full activity record as JSON (compressed) - activity_json String CODEC(ZSTD(3)), - - -- Core timestamp for time-range queries - timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), - now64(3) - ), - - -- Multi-tenant isolation - tenant_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), - - tenant_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), - - -- Origin tracking for correlation to source records - origin_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), - - origin_id String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), - - -- Change source classification (human vs system) - change_source LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), - - -- Actor information - actor_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), - - actor_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), - - actor_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), - - -- Resource information - api_group LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), - - resource_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), - - resource_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), - - resource_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), - - resource_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), - - -- Activity metadata - activity_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), - - activity_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), - - -- Summary for full-text search - summary String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), - - -- ======================================================================== - -- Skip Indexes - -- ======================================================================== - - -- Bloom filter for API group filtering (service provider queries) - INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, - - -- Bloom filter for actor-based filtering - INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Bloom filter for resource lookups - INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Minmax for change source filtering (human vs system) - INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, - - -- Full-text index for summary search (ngrams enable substring/prefix matching) - INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, - - -- ======================================================================== - -- Projections (defined inline for ReplicatedReplacingMergeTree compatibility) - -- ======================================================================== - - -- Projection for service provider queries (by API group) - PROJECTION api_group_query_projection - ( - SELECT * - ORDER BY (api_group, timestamp, tenant_type, tenant_name, resource_uid) - ), - - -- Projection for actor-based queries - PROJECTION actor_query_projection - ( - SELECT * - ORDER BY (actor_name, timestamp, tenant_type, tenant_name, resource_uid) - ) - ) - ENGINE = ReplicatedReplacingMergeTree - PARTITION BY toYYYYMMDD(timestamp) - -- Primary key optimized for tenant-scoped time-range queries - ORDER BY (tenant_type, tenant_name, timestamp, resource_uid) - PRIMARY KEY (tenant_type, tenant_name, timestamp, resource_uid) - - -- 60-day retention for activities - TTL timestamp + INTERVAL 60 DAY DELETE - - SETTINGS - storage_policy = 'default', - ttl_only_drop_parts = 1, - deduplicate_merge_projection_mode = 'rebuild'; - - 003_k8s_events_table.sql: | - -- Migration: 003_k8s_events_table - -- Description: Renames audit.events to audit.audit_logs and creates - -- audit.k8s_events for Kubernetes Events (core/v1.Event) storage. - -- Author: Claude Code - -- Date: 2026-02-17 - - -- ============================================================================ - -- Step 1: Rename Audit Log Table - -- ============================================================================ - -- Migration 001 created audit.events for audit logs. Rename it to audit.audit_logs - -- to avoid confusion with Kubernetes events and enable clearer naming. - RENAME TABLE IF EXISTS audit.events TO audit.audit_logs; - - -- ============================================================================ - -- K8s Events Table - -- ============================================================================ - -- Stores Kubernetes Events (core/v1.Event) for multi-tenant environments. - -- - -- Storage model: Insert-only with deduplication - -- - Each event state (as lastTimestamp changes) is a separate row - -- - Queries use LIMIT 1 BY uid to get latest state per event - -- - ReplacingMergeTree deduplicates true duplicates from pipeline retries - -- - -- Designed for: - -- - Multi-tenant isolation (scope_type, scope_name as primary key prefix) - -- - Efficient ordering by last_timestamp (in primary key) - -- - API group / resource queries on involved objects - -- - Platform-wide time-range queries - -- - Source component queries (by controller/component) - -- - Field selector queries (involvedObject.*, reason, type, etc.) - -- - Watch operations with ResourceVersion (using inserted_at nanoseconds) - CREATE TABLE IF NOT EXISTS audit.k8s_events - ( - -- Raw event JSON (core/v1.Event) - event_json String CODEC(ZSTD(3)), - - -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) - -- Used instead of etcd revision for watch operations - inserted_at DateTime64(9) DEFAULT now64(9), - - -- ======================================================================== - -- Multi-tenant scope (primary query dimension) - -- ======================================================================== - -- Extracted from annotations for multi-tenant isolation. - -- All queries should start with scope filtering for performance. - scope_type LowCardinality(String) MATERIALIZED - coalesce( - JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.type'), - '' - ), - - scope_name String MATERIALIZED - coalesce( - JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.name'), - '' - ), - - -- ======================================================================== - -- Timestamp fields (second query dimension) - -- ======================================================================== - first_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'firstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - last_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'lastTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'firstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - -- ======================================================================== - -- Metadata fields (from metadata.*) - -- ======================================================================== - namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'namespace'), ''), - - name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'name'), ''), - - uid String MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'uid'), ''), - - -- ======================================================================== - -- Involved Object fields (from involvedObject.*) - -- ======================================================================== - -- API group extracted from apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") - involved_api_group LowCardinality(String) MATERIALIZED - if( - position(JSONExtractString(event_json, 'involvedObject', 'apiVersion'), '/') > 0, - splitByChar('/', JSONExtractString(event_json, 'involvedObject', 'apiVersion'))[1], - '' - ), - - involved_api_version LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'involvedObject', 'apiVersion'), ''), - - involved_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'involvedObject', 'kind'), ''), - - involved_namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'involvedObject', 'namespace'), ''), - - involved_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'involvedObject', 'name'), ''), - - involved_uid String MATERIALIZED - coalesce(JSONExtractString(event_json, 'involvedObject', 'uid'), ''), - - -- ======================================================================== - -- Event classification fields - -- ======================================================================== - reason LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'reason'), ''), - - -- Type is "Normal" or "Warning" - type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'type'), 'Normal'), - - -- ======================================================================== - -- Source fields (from source.*) - -- Identifies which controller/component generated the event - -- ======================================================================== - source_component LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'source', 'component'), ''), - - source_host String MATERIALIZED - coalesce(JSONExtractString(event_json, 'source', 'host'), ''), - - -- ======================================================================== - -- Skip Indexes: Optimized for different query patterns - -- ======================================================================== - - -- Bloom filters for high-cardinality columns used in field selectors - INDEX idx_name_bloom name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_uid_bloom uid TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_involved_name_bloom involved_name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_involved_uid_bloom involved_uid TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_scope_name_bloom scope_name TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Set indexes for low-cardinality columns - INDEX idx_namespace_set namespace TYPE set(100) GRANULARITY 4, - INDEX idx_scope_type_set scope_type TYPE set(10) GRANULARITY 4, - INDEX idx_involved_api_group involved_api_group TYPE set(50) GRANULARITY 4, - INDEX idx_involved_kind_set involved_kind TYPE set(50) GRANULARITY 4, - INDEX idx_reason_set reason TYPE set(100) GRANULARITY 4, - INDEX idx_type_set type TYPE set(10) GRANULARITY 4, - INDEX idx_source_component source_component TYPE set(50) GRANULARITY 4, - - -- Timestamp minmax indexes for time-based queries - INDEX idx_first_timestamp_minmax first_timestamp TYPE minmax GRANULARITY 4, - INDEX idx_last_timestamp_minmax last_timestamp TYPE minmax GRANULARITY 4, - INDEX idx_inserted_at_minmax inserted_at TYPE minmax GRANULARITY 4, - - -- ======================================================================== - -- Projections (defined inline for ReplicatedReplacingMergeTree compatibility) - -- ======================================================================== - - -- Platform-wide queries: sorted by time across all tenants - -- Use cases: "What happened recently across the platform?" - PROJECTION platform_query_projection - ( - SELECT * - ORDER BY (last_timestamp, scope_type, scope_name, involved_api_group, involved_kind, type, uid) - ), - - -- API group / resource queries: sorted by involved object type - -- Use cases: "All events for Deployments", "Events for networking.k8s.io resources" - PROJECTION involved_object_query_projection - ( - SELECT * - ORDER BY (involved_api_group, involved_kind, scope_type, scope_name, last_timestamp, type, uid) - ), - - -- Source component queries: sorted by generating controller/component - -- Use cases: "All events from kubelet", "Events from deployment-controller" - PROJECTION source_query_projection - ( - SELECT * - ORDER BY (source_component, last_timestamp, scope_type, scope_name, involved_api_group, involved_kind, type, uid) - ) - ) - -- ================================================================== - -- TABLE ENGINE CONFIGURATION - -- ================================================================== - -- ReplicatedReplacingMergeTree provides: - -- - Deduplication of true duplicates (same ORDER BY key) during merges - -- - newer inserted_at wins when duplicates are merged - -- - HA replication across database replicas - -- - -- Note: No explicit ZooKeeper path or replica name - the audit database - -- uses the Replicated engine (migration 001) which manages replication - -- paths automatically. Specifying them explicitly is not allowed. - ENGINE = ReplicatedReplacingMergeTree(inserted_at) - PARTITION BY toYYYYMMDD(last_timestamp) - -- Primary key optimized for multi-tenant queries ordered by last_timestamp. - -- Insert-only model: each event state is a separate row, queries deduplicate - -- with LIMIT 1 BY uid. ReplacingMergeTree handles true duplicates from retries. - ORDER BY (scope_type, scope_name, last_timestamp, involved_api_group, involved_kind, type, uid) - PRIMARY KEY (scope_type, scope_name, last_timestamp, involved_api_group, involved_kind, type, uid) - - -- 60-day TTL for event retention (supports EventQuery 60-day window) - TTL last_timestamp + INTERVAL 60 DAY DELETE - - SETTINGS - -- Allow dropping parts during TTL cleanup - ttl_only_drop_parts = 1, - -- Rebuild projections during deduplication merges - deduplicate_merge_projection_mode = 'rebuild'; - - 004_eventsv1_schema_update.sql: | - -- Migration: 004_eventsv1_schema_update - -- Description: Updates k8s_events table schema to support events.k8s.io/v1 Event format. - -- This migration updates MATERIALIZED column expressions and adds new columns for - -- the events.k8s.io/v1 Event structure. - -- Author: Claude Code - -- Date: 2026-02-23 - - -- ============================================================================ - -- events.k8s.io/v1 Event Model - -- ============================================================================ - -- Key differences from core/v1: - -- - eventTime (MicroTime, required) - when event was first observed - -- - series (optional object) - for repeated events: - -- - series.count (int32) - number of occurrences - -- - series.lastObservedTime (MicroTime) - time of last occurrence - -- - regarding (ObjectReference) - replaces involvedObject - -- - related (optional ObjectReference) - secondary object - -- - note (string) - replaces message - -- - action (string, required) - what action was taken - -- - reportingController - replaces source.component - -- - reportingInstance - replaces source.host - -- - Deprecated* fields for backward compat with core/v1 - - -- ============================================================================ - -- Add new columns for events.k8s.io/v1 fields - -- ============================================================================ - - -- Action field (required in v1, describes what action was taken) - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS action LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'action'), ''); - - -- Series count (null/0 for singleton events) - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS series_count Int32 MATERIALIZED - coalesce(JSONExtractInt(event_json, 'series', 'count'), 0); - - -- Series last observed time (for repeated events, defaults to eventTime for singleton events) - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS series_last_observed DateTime64(6) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ); - - -- Is this a singleton event (no series) or part of a series? - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS is_series Bool MATERIALIZED - JSONHas(event_json, 'series'); - - -- Related object (optional secondary object in v1) - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS related_api_version LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'apiVersion'), ''); - - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS related_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'kind'), ''); - - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS related_namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'namespace'), ''); - - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS related_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'name'), ''); - - -- ============================================================================ - -- Update timestamp fields for events.k8s.io/v1 - -- ============================================================================ - -- eventTime is required and uses MicroTime (microsecond precision) - -- series.lastObservedTime is for repeated events - - -- Event time (required in v1, microsecond precision, with fallbacks for compatibility) - ALTER TABLE audit.k8s_events - ADD COLUMN IF NOT EXISTS event_time DateTime64(6) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ); - - -- Update first_timestamp to use eventTime as primary source - ALTER TABLE audit.k8s_events - MODIFY COLUMN first_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ); - - -- Update last_timestamp to use series.lastObservedTime as primary source - ALTER TABLE audit.k8s_events - MODIFY COLUMN last_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ); - - -- ============================================================================ - -- Update involved object fields (involvedObject -> regarding) - -- ============================================================================ - - -- API group extracted from regarding.apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_api_group LowCardinality(String) MATERIALIZED - if( - position(JSONExtractString(event_json, 'regarding', 'apiVersion'), '/') > 0, - splitByChar('/', JSONExtractString(event_json, 'regarding', 'apiVersion'))[1], - '' - ); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_api_version LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'apiVersion'), ''); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'kind'), ''); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'namespace'), ''); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'name'), ''); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN involved_uid String MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'uid'), ''); - - -- ============================================================================ - -- Update source fields (source.* -> reportingController/reportingInstance) - -- ============================================================================ - - ALTER TABLE audit.k8s_events - MODIFY COLUMN source_component LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'reportingController'), ''); - - ALTER TABLE audit.k8s_events - MODIFY COLUMN source_host String MATERIALIZED - coalesce(JSONExtractString(event_json, 'reportingInstance'), ''); - - -- ============================================================================ - -- Add indexes for new columns - -- ============================================================================ - - ALTER TABLE audit.k8s_events - ADD INDEX IF NOT EXISTS idx_action_set action TYPE set(100) GRANULARITY 4; - - ALTER TABLE audit.k8s_events - ADD INDEX IF NOT EXISTS idx_is_series_set is_series TYPE set(2) GRANULARITY 4; - - ALTER TABLE audit.k8s_events - ADD INDEX IF NOT EXISTS idx_event_time_minmax event_time TYPE minmax GRANULARITY 4; - - ALTER TABLE audit.k8s_events - ADD INDEX IF NOT EXISTS idx_series_last_observed_minmax series_last_observed TYPE minmax GRANULARITY 4; - - -- ============================================================================ - -- Migration Complete - -- ============================================================================ - -- The table schema now fully supports events.k8s.io/v1 Event format: - -- - eventTime with microsecond precision - -- - series.count and series.lastObservedTime for repeated events - -- - action field for describing what happened - -- - regarding (renamed from involvedObject) - -- - related (optional secondary object) - -- - reportingController/reportingInstance (renamed from source.*) - -- - -- Column names for involved object remain unchanged for query compatibility. - -- New columns added for v1-specific fields (action, series_*, event_time, related_*). - - 005_activities_reindex_support.sql: | - -- Migration: 005_activities_reindex_support - -- Description: Recreate activities table with origin_id in ORDER BY and version column for deduplication - -- Author: Activity System - -- Date: 2026-02-27 - -- - -- NOTE: This migration drops existing activities data. Activities can be regenerated - -- using the ReindexJob resource to re-process historical audit logs and events. - - -- Step 1: Drop the existing activities table - DROP TABLE IF EXISTS audit.activities; - - -- Step 2: Create new table with updated schema - CREATE TABLE audit.activities - ( - -- Full activity record as JSON (compressed) - activity_json String CODEC(ZSTD(3)), - - -- Version column for ReplacingMergeTree deduplication - -- Newer timestamp wins during merge - reindex_version DateTime64(3) DEFAULT now64(3), - - -- Core timestamp for time-range queries - timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), - now64(3) - ), - - -- Multi-tenant isolation - tenant_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), - - tenant_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), - - -- Origin tracking for correlation to source records - origin_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), - - origin_id String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), - - -- Change source classification (human vs system) - change_source LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), - - -- Actor information - actor_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), - - actor_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), - - actor_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), - - -- Resource information - api_group LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), - - resource_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), - - resource_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), - - resource_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), - - resource_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), - - -- Activity metadata - activity_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), - - activity_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), - - -- Summary for full-text search - summary String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), - - -- ======================================================================== - -- Skip Indexes - -- ======================================================================== - - -- Bloom filter for API group filtering (service provider queries) - INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, - - -- Bloom filter for actor-based filtering - INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Bloom filter for resource lookups - INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Minmax for change source filtering (human vs system) - INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, - - -- Full-text index for summary search (ngrams enable substring/prefix matching) - INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, - - -- ======================================================================== - -- Projections (updated with origin_id) - -- ======================================================================== - - -- Projection for service provider queries (by API group) - PROJECTION api_group_query_projection - ( - SELECT * - ORDER BY (api_group, timestamp, tenant_type, tenant_name, origin_id) - ), - - -- Projection for actor-based queries - PROJECTION actor_query_projection - ( - SELECT * - ORDER BY (actor_name, timestamp, tenant_type, tenant_name, origin_id) - ) - ) - ENGINE = ReplicatedReplacingMergeTree(reindex_version) - PARTITION BY toYYYYMMDD(timestamp) - -- Updated ORDER BY with origin_id for deduplication. - -- NOTE: The combination (tenant_type, tenant_name, timestamp, origin_id) is unique because - -- the activity processor follows a "first policy wins" rule - each source event (audit log - -- or Kubernetes Event) produces at most one Activity. Even if multiple ActivityPolicies - -- match the same event, only the first matching policy generates an Activity. - -- See evaluateBatch() in internal/reindex/evaluate.go. - ORDER BY (tenant_type, tenant_name, timestamp, origin_id) - PRIMARY KEY (tenant_type, tenant_name, timestamp, origin_id) - - -- 60-day retention for activities - TTL timestamp + INTERVAL 60 DAY DELETE - - SETTINGS - storage_policy = 'default', - ttl_only_drop_parts = 1, - deduplicate_merge_projection_mode = 'rebuild'; - - 006_rename_involved_to_regarding.sql: | - -- Migration: 006_rename_involved_to_regarding - -- Description: Renames involved_* columns to regarding_* to align with events.k8s.io/v1 canonical naming. - -- Requires table recreation because columns are part of ORDER BY key and projections. - -- Author: Claude Code - -- Date: 2026-03-04 - - -- ============================================================================ - -- Strategy: Create new table, copy data, swap tables - -- ============================================================================ - -- ClickHouse doesn't allow renaming columns that are part of the sorting key - -- (ORDER BY / PRIMARY KEY). We must recreate the table with the new schema. - -- - -- Steps: - -- 1. Create k8s_events_new with regarding_* column names - -- 2. Insert all data from k8s_events (columns auto-populated from event_json) - -- 3. Drop original k8s_events table - -- 4. Rename k8s_events_new to k8s_events - - -- ============================================================================ - -- Step 1: Create new table with regarding_* column names - -- ============================================================================ - CREATE TABLE IF NOT EXISTS audit.k8s_events_new - ( - -- Raw event JSON (events.k8s.io/v1.Event) - event_json String CODEC(ZSTD(3)), - - -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) - inserted_at DateTime64(9) DEFAULT now64(9), - - -- ======================================================================== - -- Multi-tenant scope (primary query dimension) - -- ======================================================================== - scope_type LowCardinality(String) MATERIALIZED - coalesce( - JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.type'), - '' - ), - - scope_name String MATERIALIZED - coalesce( - JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.name'), - '' - ), - - -- ======================================================================== - -- Timestamp fields - -- ======================================================================== - first_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - last_timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - event_time DateTime64(6) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - -- ======================================================================== - -- Metadata fields (from metadata.*) - -- ======================================================================== - namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'namespace'), ''), - - name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'name'), ''), - - uid String MATERIALIZED - coalesce(JSONExtractString(event_json, 'metadata', 'uid'), ''), - - -- ======================================================================== - -- Regarding Object fields (from regarding.* - events.k8s.io/v1) - -- ======================================================================== - -- API group extracted from apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") - regarding_api_group LowCardinality(String) MATERIALIZED - if( - position(JSONExtractString(event_json, 'regarding', 'apiVersion'), '/') > 0, - splitByChar('/', JSONExtractString(event_json, 'regarding', 'apiVersion'))[1], - '' - ), - - regarding_api_version LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'apiVersion'), ''), - - regarding_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'kind'), ''), - - regarding_namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'namespace'), ''), - - regarding_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'name'), ''), - - regarding_uid String MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'uid'), ''), - - regarding_field_path String MATERIALIZED - coalesce(JSONExtractString(event_json, 'regarding', 'fieldPath'), ''), - - -- ======================================================================== - -- Related Object fields (optional secondary object in events.k8s.io/v1) - -- ======================================================================== - related_api_version LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'apiVersion'), ''), - - related_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'kind'), ''), - - related_namespace LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'namespace'), ''), - - related_name String MATERIALIZED - coalesce(JSONExtractString(event_json, 'related', 'name'), ''), - - -- ======================================================================== - -- Event classification fields - -- ======================================================================== - reason LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'reason'), ''), - - -- Type is "Normal" or "Warning" - type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'type'), 'Normal'), - - -- Action field (required in v1, describes what action was taken) - action LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'action'), ''), - - -- ======================================================================== - -- Series fields (for repeated events in events.k8s.io/v1) - -- ======================================================================== - series_count Int32 MATERIALIZED - coalesce(JSONExtractInt(event_json, 'series', 'count'), 0), - - series_last_observed DateTime64(6) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), - parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) - ), - - is_series Bool MATERIALIZED - JSONHas(event_json, 'series'), - - -- ======================================================================== - -- Source fields (reportingController/reportingInstance in events.k8s.io/v1) - -- ======================================================================== - source_component LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(event_json, 'reportingController'), ''), - - source_host String MATERIALIZED - coalesce(JSONExtractString(event_json, 'reportingInstance'), ''), - - -- ======================================================================== - -- Skip Indexes - -- ======================================================================== - -- Bloom filters for high-cardinality columns - INDEX idx_name_bloom name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_uid_bloom uid TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_regarding_name_bloom regarding_name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_regarding_uid_bloom regarding_uid TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_scope_name_bloom scope_name TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Set indexes for low-cardinality columns - INDEX idx_namespace_set namespace TYPE set(100) GRANULARITY 4, - INDEX idx_scope_type_set scope_type TYPE set(10) GRANULARITY 4, - INDEX idx_regarding_api_group regarding_api_group TYPE set(50) GRANULARITY 4, - INDEX idx_regarding_kind_set regarding_kind TYPE set(50) GRANULARITY 4, - INDEX idx_reason_set reason TYPE set(100) GRANULARITY 4, - INDEX idx_type_set type TYPE set(10) GRANULARITY 4, - INDEX idx_source_component source_component TYPE set(50) GRANULARITY 4, - INDEX idx_action_set action TYPE set(100) GRANULARITY 4, - INDEX idx_is_series_set is_series TYPE set(2) GRANULARITY 4, - - -- Timestamp minmax indexes - INDEX idx_first_timestamp_minmax first_timestamp TYPE minmax GRANULARITY 4, - INDEX idx_last_timestamp_minmax last_timestamp TYPE minmax GRANULARITY 4, - INDEX idx_inserted_at_minmax inserted_at TYPE minmax GRANULARITY 4, - INDEX idx_event_time_minmax event_time TYPE minmax GRANULARITY 4, - INDEX idx_series_last_observed_minmax series_last_observed TYPE minmax GRANULARITY 4, - - -- ======================================================================== - -- Projections (using regarding_* column names) - -- ======================================================================== - - -- Platform-wide queries: sorted by time across all tenants - PROJECTION platform_query_projection - ( - SELECT * - ORDER BY (last_timestamp, scope_type, scope_name, regarding_api_group, regarding_kind, type, uid) - ), - - -- API group / resource queries: sorted by regarding object type - PROJECTION regarding_object_query_projection - ( - SELECT * - ORDER BY (regarding_api_group, regarding_kind, scope_type, scope_name, last_timestamp, type, uid) - ), - - -- Source component queries: sorted by generating controller/component - PROJECTION source_query_projection - ( - SELECT * - ORDER BY (source_component, last_timestamp, scope_type, scope_name, regarding_api_group, regarding_kind, type, uid) - ) - ) - ENGINE = ReplicatedReplacingMergeTree(inserted_at) - PARTITION BY toYYYYMMDD(last_timestamp) - ORDER BY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) - PRIMARY KEY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) - TTL last_timestamp + INTERVAL 60 DAY DELETE - SETTINGS - ttl_only_drop_parts = 1, - deduplicate_merge_projection_mode = 'rebuild'; - - -- ============================================================================ - -- Step 2: Copy data from old table to new table - -- ============================================================================ - -- Only copy event_json and inserted_at - MATERIALIZED columns auto-populate - INSERT INTO audit.k8s_events_new (event_json, inserted_at) - SELECT event_json, inserted_at - FROM audit.k8s_events; - - -- ============================================================================ - -- Step 3: Swap tables - -- ============================================================================ - -- Drop old table - DROP TABLE IF EXISTS audit.k8s_events; - - -- Rename new table to original name - RENAME TABLE audit.k8s_events_new TO audit.k8s_events; - - -- ============================================================================ - -- Migration Complete - -- ============================================================================ - -- The k8s_events table now uses regarding_* column names throughout: - -- - regarding_api_group, regarding_api_version, regarding_kind - -- - regarding_namespace, regarding_name, regarding_uid, regarding_field_path - -- - ORDER BY and PRIMARY KEY use regarding_api_group, regarding_kind - -- - Projections renamed to use regarding_* columns - -- - Indexes renamed to idx_regarding_* - -- - -- Storage layer code (Go) must use regarding_* column names in queries. - - 007_activities_table_v2.sql: | - -- Migration: 007_activities_table_v2 - -- Description: Recreate activities table with time-bucketed projections aligned with audit_logs patterns - -- Author: Activity System - -- Date: 2026-03-10 - -- - -- NOTE: This migration drops existing activities data. Activities can be regenerated - -- using the ReindexJob resource to re-process historical audit logs and events. - - -- Step 1: Drop the existing activities table - DROP TABLE IF EXISTS audit.activities; - - -- Step 2: Create new table with updated schema and time-bucketed projections - CREATE TABLE audit.activities - ( - -- Full activity record as JSON (compressed) - activity_json String CODEC(ZSTD(3)), - - -- Version column for ReplacingMergeTree deduplication - -- Newer timestamp wins during merge - reindex_version DateTime64(3) DEFAULT now64(3), - - -- Core timestamp for time-range queries - timestamp DateTime64(3) MATERIALIZED - coalesce( - parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), - now64(3) - ), - - -- Multi-tenant isolation - tenant_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), - - tenant_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), - - -- Origin tracking for correlation to source records - origin_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), - - origin_id String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), - - -- Change source classification (human vs system) - change_source LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), - - -- Actor information - actor_type LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), - - actor_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), - - actor_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), - - -- Resource information - api_group LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), - - resource_kind LowCardinality(String) MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), - - resource_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), - - resource_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), - - resource_uid String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), - - -- Activity metadata - activity_name String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), - - activity_namespace String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), - - -- Summary for full-text search - summary String MATERIALIZED - coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), - - -- ======================================================================== - -- Skip Indexes - -- ======================================================================== - - -- Bloom filter for API group filtering (service provider queries) - INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, - - -- Bloom filter for actor-based filtering - INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, - INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Bloom filter for resource lookups - INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, - INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, - - -- Minmax for change source filtering (human vs system) - INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, - - -- Full-text index for summary search (ngrams enable substring/prefix matching) - INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, - - -- ======================================================================== - -- Projections (time-bucketed, aligned with audit_logs patterns) - -- ======================================================================== - - -- Projection for platform-wide queries by API group - PROJECTION platform_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, api_group, resource_kind, resource_uid) - ), - - -- Projection for actor-name-based queries (platform-wide) - PROJECTION actor_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, actor_name, api_group, resource_kind, resource_uid) - ), - - -- Projection for actor-uid-based queries (user scope) - PROJECTION actor_uid_query_projection - ( - SELECT * - ORDER BY (toStartOfHour(timestamp), timestamp, actor_uid, api_group, resource_kind, resource_uid) - ) - ) - ENGINE = ReplicatedReplacingMergeTree(reindex_version) - PARTITION BY toYYYYMMDD(timestamp) - -- Primary key uses toStartOfHour(timestamp) as the leading column for efficient - -- time-bucketed queries, matching the projection sort orders. - -- origin_id provides deduplication: the activity processor follows a - -- "first policy wins" rule so each source event produces at most one Activity. - ORDER BY (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) - PRIMARY KEY (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) - - -- 60-day retention for activities - TTL timestamp + INTERVAL 60 DAY DELETE - - SETTINGS - storage_policy = 'default', - ttl_only_drop_parts = 1, - deduplicate_merge_projection_mode = 'rebuild'; - +# AUTO-GENERATED - DO NOT EDIT MANUALLY +# Generated from migrations/ directory (source of truth) +# To regenerate: task migrations:generate OR ./hack/generate-migrations-configmap.sh +# +# To add a new migration: +# 1. Create file in migrations/ (e.g., migrations/002_add_field.sql) +# 2. Run: task migrations:generate +# 3. Update job.yaml to include the new migration in volumes +# 4. Deploy: task dev:deploy + +apiVersion: v1 +kind: ConfigMap +metadata: + name: clickhouse-migrations + namespace: activity-system + labels: + app: clickhouse-migrations + app.kubernetes.io/component: database +data: + # Migration runner script + migrate.sh: | + #!/bin/bash + set -euo pipefail + + # ClickHouse Migration Runner + # This script applies versioned SQL migrations to a ClickHouse database + # It tracks applied migrations in the audit.schema_migrations table + + # Configuration from environment variables + CLICKHOUSE_HOST="${CLICKHOUSE_HOST:-clickhouse}" + CLICKHOUSE_PORT="${CLICKHOUSE_PORT:-9000}" + CLICKHOUSE_USER="${CLICKHOUSE_USER:-default}" + CLICKHOUSE_PASSWORD="${CLICKHOUSE_PASSWORD:-}" + CLICKHOUSE_DATABASE="${CLICKHOUSE_DATABASE:-audit}" + MIGRATIONS_DIR="${MIGRATIONS_DIR:-/migrations}" + CLICKHOUSE_SECURE="${CLICKHOUSE_SECURE:-false}" + CLICKHOUSE_CLIENT_EXTRA_ARGS="${CLICKHOUSE_CLIENT_EXTRA_ARGS:-}" + + # Colors for output + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[1;33m' + BLUE='\033[0;34m' + NC='\033[0m' # No Color + + log_info() { + echo -e "${BLUE}[INFO]${NC} $1" + } + + log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" + } + + log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" + } + + log_error() { + echo -e "${RED}[ERROR]${NC} $1" + } + + # Build clickhouse-client command with authentication + clickhouse_cmd() { + local query="$1" + local cmd="clickhouse-client ${CLICKHOUSE_CLIENT_EXTRA_ARGS} --host=${CLICKHOUSE_HOST} --port=${CLICKHOUSE_PORT} --user=${CLICKHOUSE_USER}" + + if [ -n "${CLICKHOUSE_PASSWORD}" ]; then + cmd="${cmd} --password=${CLICKHOUSE_PASSWORD}" + fi + + echo "${query}" | ${cmd} + } + + # Wait for ClickHouse to be ready + wait_for_clickhouse() { + log_info "Waiting for ClickHouse to be ready at ${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}..." + + local max_attempts=30 + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if clickhouse_cmd "SELECT 1" &>/dev/null; then + log_success "ClickHouse is ready!" + return 0 + fi + + log_info "Attempt $attempt/$max_attempts: ClickHouse not ready yet, waiting..." + sleep 2 + attempt=$((attempt + 1)) + done + + log_error "ClickHouse did not become ready within the timeout period" + return 1 + } + + # Wait for all replicas in the cluster to be healthy and ready + # This function will wait indefinitely until all replicas are online and healthy + wait_for_cluster_ready() { + local expected_replicas="${EXPECTED_REPLICAS:-3}" + log_info "Waiting for all ${expected_replicas} replicas in the 'activity' cluster to be ready..." + log_info "This will wait indefinitely until the cluster is healthy." + local attempt=1 + + while true; do + # Check if the 'activity' cluster exists and has the expected number of replicas + local cluster_exists=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity'" 2>/dev/null || echo "0") + + if [ "$cluster_exists" -eq 0 ]; then + log_info "Attempt $attempt: 'activity' cluster not yet registered in system.clusters, waiting..." + sleep 5 + attempt=$((attempt + 1)) + continue + fi + + # Get total number of replicas in the cluster + local total_replicas=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity'" 2>/dev/null || echo "0") + + # Get number of healthy replicas (errors_count=0 means no connection errors) + local healthy_replicas=$(clickhouse_cmd "SELECT count() FROM system.clusters WHERE cluster='activity' AND errors_count=0" 2>/dev/null || echo "0") + + # Check if we have the expected number of replicas and all are healthy + if [ "$total_replicas" -eq "$expected_replicas" ] && [ "$healthy_replicas" -eq "$expected_replicas" ]; then + log_success "All $expected_replicas replicas are registered and healthy!" + + # Additional check: verify Keeper connectivity for distributed DDL + log_info "Verifying ClickHouse Keeper connectivity for distributed DDL..." + if clickhouse_cmd "SELECT count() FROM system.zookeeper WHERE path='/clickhouse/activity'" &>/dev/null; then + log_success "ClickHouse Keeper is accessible and cluster coordination is ready!" + + # Final verification: display cluster topology + log_info "Cluster topology:" + clickhouse_cmd " + SELECT + cluster, + shard_num, + replica_num, + host_name, + port, + errors_count + FROM system.clusters + WHERE cluster = 'activity' + ORDER BY shard_num, replica_num + FORMAT PrettyCompact + " || true + + return 0 + else + log_info "Attempt $attempt: Keeper connectivity not ready yet, waiting..." + fi + else + log_info "Attempt $attempt: $healthy_replicas/$total_replicas healthy replicas (expected: $expected_replicas), waiting..." + fi + + sleep 5 + attempt=$((attempt + 1)) + done + } + + # Initialize the schema_migrations table if it doesn't exist + init_migrations_table() { + log_info "Verifying schema_migrations table..." + + # Note: Both database and schema_migrations table creation are handled by the + # first migration (001_initial_schema.sql). This function simply verifies + # the table exists before we try to query it for already-applied migrations. + # + # We don't create it here because: + # 1. The table should be created with the Replicated database engine for HA + # 2. All schema changes should go through the migration system for consistency + # 3. The first migration will create both the database and this tracking table + + # Check if the table exists (will be created by first migration if not) + local table_exists=$(clickhouse_cmd " + SELECT count() + FROM system.tables + WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'schema_migrations' + " 2>/dev/null || echo "0") + + if [ "${table_exists}" -eq 0 ]; then + log_info "Schema migrations table does not exist yet - will be created by first migration" + else + log_success "Schema migrations table exists and is ready" + fi + } + + # Calculate checksum of a file + calculate_checksum() { + local file="$1" + sha256sum "${file}" | awk '{print $1}' + } + + # Check if a migration has already been applied + is_migration_applied() { + local version="$1" + + # If the schema_migrations table doesn't exist yet, no migrations have been applied + # This handles the case for the very first migration which creates the table + local result=$(clickhouse_cmd " + SELECT count(*) + FROM ${CLICKHOUSE_DATABASE}.schema_migrations + WHERE version = ${version} + " 2>/dev/null || echo "0") + + [ "${result}" -gt 0 ] + } + + # Record a migration as applied + record_migration() { + local version="$1" + local name="$2" + local checksum="$3" + + clickhouse_cmd " + INSERT INTO ${CLICKHOUSE_DATABASE}.schema_migrations + (version, name, checksum) + VALUES (${version}, '${name}', '${checksum}') + " + } + + # Apply a single migration file + apply_migration() { + local migration_file="$1" + local filename=$(basename "${migration_file}") + + # Extract version and name from filename (e.g., 001_initial_schema.sql) + if [[ ! "${filename}" =~ ^([0-9]{3})_(.+)\.sql$ ]]; then + log_warning "Skipping ${filename}: doesn't match naming convention {version}_{name}.sql" + return 0 + fi + + local version="${BASH_REMATCH[1]}" + local name="${BASH_REMATCH[2]}" + local version_num=$((10#${version})) # Convert to decimal, removing leading zeros + local checksum=$(calculate_checksum "${migration_file}") + + # Check if already applied + if is_migration_applied "${version_num}"; then + log_info "Migration ${version}_${name} already applied, skipping" + return 0 + fi + + log_info "Applying migration ${version}_${name}..." + + # Read and execute the migration file + # We use --multiquery to allow multiple statements in one file + local cmd="clickhouse-client ${CLICKHOUSE_CLIENT_EXTRA_ARGS} --host=${CLICKHOUSE_HOST} --port=${CLICKHOUSE_PORT} --user=${CLICKHOUSE_USER}" + + if [ -n "${CLICKHOUSE_PASSWORD}" ]; then + cmd="${cmd} --password=${CLICKHOUSE_PASSWORD}" + fi + + cmd="${cmd} --multiquery" + + if cat "${migration_file}" | ${cmd}; then + # Record the migration as applied + record_migration "${version_num}" "${name}" "${checksum}" + log_success "Migration ${version}_${name} applied successfully" + return 0 + else + log_error "Failed to apply migration ${version}_${name}" + return 1 + fi + } + + # Apply all pending migrations + apply_migrations() { + log_info "Looking for migration files in ${MIGRATIONS_DIR}..." + + if [ ! -d "${MIGRATIONS_DIR}" ]; then + log_error "Migrations directory ${MIGRATIONS_DIR} not found" + return 1 + fi + + # Find all .sql files and sort them by version number + # Note: ConfigMaps in Kubernetes mount files as symlinks, so we don't use -type f + local migration_files=$(find "${MIGRATIONS_DIR}" -maxdepth 1 -name "*.sql" | sort) + + if [ -z "${migration_files}" ]; then + log_warning "No migration files found in ${MIGRATIONS_DIR}" + return 0 + fi + + local migrations_count=0 + local applied_count=0 + + while IFS= read -r migration_file; do + migrations_count=$((migrations_count + 1)) + if apply_migration "${migration_file}"; then + applied_count=$((applied_count + 1)) + else + log_error "Migration failed, stopping" + return 1 + fi + done <<< "${migration_files}" + + log_success "Migrations complete: ${applied_count} applied out of ${migrations_count} total" + + # Show current migration status + show_migration_status + } + + # Show current migration status + show_migration_status() { + log_info "Current migration status:" + clickhouse_cmd " + SELECT + version, + name, + applied_at, + substring(checksum, 1, 12) as checksum_short + FROM ${CLICKHOUSE_DATABASE}.schema_migrations + ORDER BY version + FORMAT PrettyCompact + " || log_warning "Could not fetch migration status" + } + + # Verify schema matches expected state + verify_schema() { + log_info "Verifying schema..." + + # Check if audit.audit_logs table exists (renamed from events in migration 003) + local audit_logs_table_exists=$(clickhouse_cmd " + SELECT count() + FROM system.tables + WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'audit_logs' + ") + + if [ "${audit_logs_table_exists}" -eq 0 ]; then + log_error "Table ${CLICKHOUSE_DATABASE}.audit_logs does not exist!" + return 1 + fi + + # Check if audit.k8s_events table exists (created in migration 003) + local k8s_events_table_exists=$(clickhouse_cmd " + SELECT count() + FROM system.tables + WHERE database = '${CLICKHOUSE_DATABASE}' AND name = 'k8s_events' + ") + + if [ "${k8s_events_table_exists}" -eq 0 ]; then + log_error "Table ${CLICKHOUSE_DATABASE}.k8s_events does not exist!" + return 1 + fi + + log_success "Schema verification passed" + + # Show table structure + log_info "audit_logs table structure:" + clickhouse_cmd " + DESCRIBE TABLE ${CLICKHOUSE_DATABASE}.audit_logs + FORMAT PrettyCompact + " || true + + log_info "k8s_events table structure:" + clickhouse_cmd " + DESCRIBE TABLE ${CLICKHOUSE_DATABASE}.k8s_events + FORMAT PrettyCompact + " || true + } + + # Main execution + main() { + log_info "ClickHouse Migration Runner Starting..." + log_info "Target: ${CLICKHOUSE_HOST}:${CLICKHOUSE_PORT}" + log_info "Database: ${CLICKHOUSE_DATABASE}" + log_info "Migrations Directory: ${MIGRATIONS_DIR}" + echo "" + + log_info "IMPORTANT: This migration script should only run against a single replica." + log_info "The Replicated database engine automatically propagates DDL changes to all replicas." + echo "" + + # Wait for ClickHouse to be ready + if ! wait_for_clickhouse; then + log_error "Failed to connect to ClickHouse" + exit 1 + fi + + # Display which replica we're connected to + log_info "Connected to replica:" + clickhouse_cmd "SELECT hostName() as host, getMacro('replica') as replica_name" || true + + echo "" + + # Wait for all cluster replicas to be healthy + if ! wait_for_cluster_ready; then + log_error "Cluster is not fully healthy" + exit 1 + fi + + echo "" + + # Verify migrations tracking (table will be created by first migration) + init_migrations_table + + echo "" + + # Apply all pending migrations + if ! apply_migrations; then + log_error "Migration process failed" + exit 1 + fi + + echo "" + + # Verify schema + if ! verify_schema; then + log_error "Schema verification failed" + exit 1 + fi + + echo "" + log_success "All migrations completed successfully!" + } + + # Run main function + main "$@" + + 001_initial_schema.sql: | + -- Migration: 001_initial_schema + -- Description: High-volume multi-tenant audit events table with HA replication + -- and projections for platform-wide querying and user-specific querying. + -- Author: Scot Wells + -- Date: 2025-12-11 + -- Updated: 2026-01-15 - Added HA replication support with ReplicatedReplacingMergeTree + -- Updated: 2026-01-16 - Use Replicated database engine for automatic DDL replication + + -- ============================================================================ + -- Step 1: Create Replicated Database + -- ============================================================================ + -- The Replicated database engine automatically replicates all DDL operations + -- across all replicas in the cluster. This ensures schema consistency without + -- requiring ON CLUSTER clauses. + -- + -- UUID ensures the database has the same identifier on all replicas + -- Path: /clickhouse/activity/databases/audit in ClickHouse Keeper + -- Macros: {shard} and {replica} are automatically substituted by ClickHouse + CREATE DATABASE IF NOT EXISTS audit ON CLUSTER 'activity' + ENGINE = Replicated('/clickhouse/activity/databases/audit', '{shard}', '{replica}'); + + -- ============================================================================ + -- Step 2: Create Schema Migrations Tracking Table + -- ============================================================================ + -- This table tracks which migrations have been applied to prevent re-running + -- them. Each migration records its version, name, application timestamp, and + -- checksum for integrity verification. + CREATE TABLE IF NOT EXISTS audit.schema_migrations + ( + version UInt32, + name String, + applied_at DateTime64(3) DEFAULT now64(3), + checksum String + ) ENGINE = ReplicatedReplacingMergeTree() + ORDER BY version + SETTINGS + -- No special storage policy needed for this small metadata table + storage_policy = 'default'; + + -- ============================================================================ + -- Step 3: Create Events Table + -- ============================================================================ + -- Replicated database automatically replicates table DDL - no need for ON CLUSTER + CREATE TABLE IF NOT EXISTS audit.events + ( + -- Raw audit event JSON + event_json String CODEC(ZSTD(3)), + + -- Core timestamp (always queried) + -- Uses requestReceivedTimestamp which represents when the API server received the request. + timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'requestReceivedTimestamp')), + now64(3) + ), + + -- Scope annotations (multi-tenant scoping) + scope_type LowCardinality(String) MATERIALIZED + coalesce( + JSONExtractString(event_json, 'annotations', 'platform.miloapis.com/scope.type'), + '' + ), + + scope_name String MATERIALIZED + coalesce( + JSONExtractString(event_json, 'annotations', 'platform.miloapis.com/scope.name'), + '' + ), + + -- User identity + user String MATERIALIZED + coalesce( + JSONExtractString(event_json, 'user', 'username'), + '' + ), + + user_uid String MATERIALIZED + coalesce( + JSONExtractString(event_json, 'user', 'uid'), + '' + ), + + -- Request identity + audit_id UUID MATERIALIZED + toUUIDOrZero(coalesce(JSONExtractString(event_json, 'auditID'), '')), + + -- Common filters + verb LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'verb'), ''), + + api_group LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'objectRef', 'apiGroup'), ''), + + resource LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'objectRef', 'resource'), ''), + + namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'objectRef', 'namespace'), ''), + + resource_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'objectRef', 'name'), ''), + + status_code UInt16 MATERIALIZED + toUInt16OrZero(JSONExtractString(event_json, 'responseStatus', 'code')), + + -- ======================================================================== + -- Skip Indexes: Optimized for different query patterns + -- ======================================================================== + + -- Timestamp minmax index for time range queries + INDEX idx_timestamp_minmax timestamp TYPE minmax GRANULARITY 4, + + -- Bloom filters with GRANULARITY 1 for high precision (critical filters) + INDEX idx_verb_set verb TYPE set(10) GRANULARITY 4, + INDEX idx_resource_bloom resource TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX bf_api_resource (api_group, resource) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_verb_resource_bloom (verb, resource) TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_user_bloom user TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_user_uid_bloom user_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Set indexes for low-cardinality columns + INDEX idx_status_code_set status_code TYPE set(100) GRANULARITY 4, + -- Minmax index for status_code range queries + INDEX idx_status_code_minmax status_code TYPE minmax GRANULARITY 4, + ) + -- ================================================================== + -- TABLE ENGINE CONFIGURATION (High Availability) + -- ================================================================== + -- ReplicatedReplacingMergeTree provides: + -- - Deduplication based on ORDER BY key during merges + -- - Eventual consistency with quorum writes (configured via settings) + -- - Data replication to other database replicas + -- + -- Replication Behavior: + -- - INSERT on any replica replicates to all replicas via Keeper (database-level) + -- - Quorum writes ensure 2/3 replicas acknowledge before success + -- - Deduplication happens during background merges + ENGINE = ReplicatedReplacingMergeTree + PARTITION BY toYYYYMMDD(timestamp) + -- Primary key optimized for tenant-scoped queries with hour bucketing + -- Hour bucketing provides data locality while timestamp ensures strict chronological order + -- Timestamp as second key ensures events are always returned in time order for audit compliance + -- Deduplication occurs on the full ORDER BY key during merges + ORDER BY (toStartOfHour(timestamp), timestamp, scope_type, scope_name, user, audit_id) + PRIMARY KEY (toStartOfHour(timestamp), timestamp, scope_type, scope_name, user, audit_id) + + -- Move parts to cold S3-backed volume after 90 days + TTL timestamp + INTERVAL 90 DAY TO VOLUME 'cold' + + SETTINGS + storage_policy = 'hot_cold', + ttl_only_drop_parts = 1, + deduplicate_merge_projection_mode = 'rebuild'; + + -- ============================================================================ + -- Step 4: Add Platform Query Projection + -- ============================================================================ + -- This projection is optimized for platform-wide queries that filter by + -- timestamp, api_group, and resource (common for cross-tenant analytics). + -- + -- Sort order: (toStartOfHour(timestamp), timestamp, api_group, resource, audit_id) + -- Use cases: + -- - "All events for 'apps' API group and 'deployments' resource in last 24 hours" + -- - "All events for core API 'pods' resource" + -- - Platform-wide verb/resource filtering + -- + -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. + -- Timestamp as second key ensures events are always returned in time order for audit compliance. + + ALTER TABLE audit.events + ADD PROJECTION platform_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, api_group, resource, audit_id) + ); + + -- ============================================================================ + -- Step 5: Add User Query Projection + -- ============================================================================ + -- This projection is optimized for username-based queries within time ranges. + -- + -- Sort order: (toStartOfHour(timestamp), timestamp, user, api_group, resource, audit_id) + -- Use cases: + -- - "What did alice@example.com do in the last 24 hours?" + -- - "All events by system:serviceaccount:kube-system:default" + -- - Platform admin filtering by username in CEL expressions + -- + -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. + -- Timestamp as second key ensures events are always returned in time order for audit compliance. + -- ClickHouse automatically chooses the best projection for each query based + -- on the WHERE clause filters. + + ALTER TABLE audit.events + ADD PROJECTION user_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, user, api_group, resource, audit_id) + ); + + -- ============================================================================ + -- Step 6: Add User UID Query Projection + -- ============================================================================ + -- This projection is optimized for user-scoped queries by UID. + -- + -- Sort order: (toStartOfHour(timestamp), timestamp, user_uid, api_group, resource, audit_id) + -- Use cases: + -- - User-scoped queries: "Show all activity by user with UID abc-123" + -- - Cross-organization user activity tracking + -- - User-specific audit trail regardless of username changes + -- + -- This projection is used when scope.type == "user" to filter by user_uid + -- instead of scope_name, enabling queries for a user's activity across all + -- organizations and projects on the platform. + -- + -- Hour bucketing provides index efficiency while timestamp ensures strict chronological order. + -- Timestamp as second key ensures events are always returned in time order for audit compliance. + + ALTER TABLE audit.events + ADD PROJECTION user_uid_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, user_uid, api_group, resource, audit_id) + ); + + 002_activities_table.sql: | + -- Migration: 002_activities_table + -- Description: Activities table for storing translated activity records + -- Author: Activity System + -- Date: 2026-02-02 + + -- ============================================================================ + -- Activities Table + -- ============================================================================ + -- Stores translated activity records generated by the Activity Processor. + -- These are human-readable summaries of audit logs and Kubernetes events, + -- optimized for time-range queries and multi-tenant isolation. + + CREATE TABLE IF NOT EXISTS audit.activities + ( + -- Full activity record as JSON (compressed) + activity_json String CODEC(ZSTD(3)), + + -- Core timestamp for time-range queries + timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), + now64(3) + ), + + -- Multi-tenant isolation + tenant_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), + + tenant_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), + + -- Origin tracking for correlation to source records + origin_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), + + origin_id String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), + + -- Change source classification (human vs system) + change_source LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), + + -- Actor information + actor_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), + + actor_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), + + actor_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), + + -- Resource information + api_group LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), + + resource_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), + + resource_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), + + resource_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), + + resource_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), + + -- Activity metadata + activity_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), + + activity_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), + + -- Summary for full-text search + summary String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), + + -- ======================================================================== + -- Skip Indexes + -- ======================================================================== + + -- Bloom filter for API group filtering (service provider queries) + INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, + + -- Bloom filter for actor-based filtering + INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Bloom filter for resource lookups + INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Minmax for change source filtering (human vs system) + INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, + + -- Full-text index for summary search (ngrams enable substring/prefix matching) + INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, + + -- ======================================================================== + -- Projections (defined inline for ReplicatedReplacingMergeTree compatibility) + -- ======================================================================== + + -- Projection for service provider queries (by API group) + PROJECTION api_group_query_projection + ( + SELECT * + ORDER BY (api_group, timestamp, tenant_type, tenant_name, resource_uid) + ), + + -- Projection for actor-based queries + PROJECTION actor_query_projection + ( + SELECT * + ORDER BY (actor_name, timestamp, tenant_type, tenant_name, resource_uid) + ) + ) + ENGINE = ReplicatedReplacingMergeTree + PARTITION BY toYYYYMMDD(timestamp) + -- Primary key optimized for tenant-scoped time-range queries + ORDER BY (tenant_type, tenant_name, timestamp, resource_uid) + PRIMARY KEY (tenant_type, tenant_name, timestamp, resource_uid) + + -- 60-day retention for activities + TTL timestamp + INTERVAL 60 DAY DELETE + + SETTINGS + storage_policy = 'default', + ttl_only_drop_parts = 1, + deduplicate_merge_projection_mode = 'rebuild'; + + 003_k8s_events_table.sql: | + -- Migration: 003_k8s_events_table + -- Description: Renames audit.events to audit.audit_logs and creates + -- audit.k8s_events for Kubernetes Events (core/v1.Event) storage. + -- Author: Claude Code + -- Date: 2026-02-17 + + -- ============================================================================ + -- Step 1: Rename Audit Log Table + -- ============================================================================ + -- Migration 001 created audit.events for audit logs. Rename it to audit.audit_logs + -- to avoid confusion with Kubernetes events and enable clearer naming. + RENAME TABLE IF EXISTS audit.events TO audit.audit_logs; + + -- ============================================================================ + -- K8s Events Table + -- ============================================================================ + -- Stores Kubernetes Events (core/v1.Event) for multi-tenant environments. + -- + -- Storage model: Insert-only with deduplication + -- - Each event state (as lastTimestamp changes) is a separate row + -- - Queries use LIMIT 1 BY uid to get latest state per event + -- - ReplacingMergeTree deduplicates true duplicates from pipeline retries + -- + -- Designed for: + -- - Multi-tenant isolation (scope_type, scope_name as primary key prefix) + -- - Efficient ordering by last_timestamp (in primary key) + -- - API group / resource queries on involved objects + -- - Platform-wide time-range queries + -- - Source component queries (by controller/component) + -- - Field selector queries (involvedObject.*, reason, type, etc.) + -- - Watch operations with ResourceVersion (using inserted_at nanoseconds) + CREATE TABLE IF NOT EXISTS audit.k8s_events + ( + -- Raw event JSON (core/v1.Event) + event_json String CODEC(ZSTD(3)), + + -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) + -- Used instead of etcd revision for watch operations + inserted_at DateTime64(9) DEFAULT now64(9), + + -- ======================================================================== + -- Multi-tenant scope (primary query dimension) + -- ======================================================================== + -- Extracted from annotations for multi-tenant isolation. + -- All queries should start with scope filtering for performance. + scope_type LowCardinality(String) MATERIALIZED + coalesce( + JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.type'), + '' + ), + + scope_name String MATERIALIZED + coalesce( + JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.name'), + '' + ), + + -- ======================================================================== + -- Timestamp fields (second query dimension) + -- ======================================================================== + first_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'firstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + last_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'lastTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'firstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + -- ======================================================================== + -- Metadata fields (from metadata.*) + -- ======================================================================== + namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'namespace'), ''), + + name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'name'), ''), + + uid String MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'uid'), ''), + + -- ======================================================================== + -- Involved Object fields (from involvedObject.*) + -- ======================================================================== + -- API group extracted from apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") + involved_api_group LowCardinality(String) MATERIALIZED + if( + position(JSONExtractString(event_json, 'involvedObject', 'apiVersion'), '/') > 0, + splitByChar('/', JSONExtractString(event_json, 'involvedObject', 'apiVersion'))[1], + '' + ), + + involved_api_version LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'involvedObject', 'apiVersion'), ''), + + involved_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'involvedObject', 'kind'), ''), + + involved_namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'involvedObject', 'namespace'), ''), + + involved_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'involvedObject', 'name'), ''), + + involved_uid String MATERIALIZED + coalesce(JSONExtractString(event_json, 'involvedObject', 'uid'), ''), + + -- ======================================================================== + -- Event classification fields + -- ======================================================================== + reason LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'reason'), ''), + + -- Type is "Normal" or "Warning" + type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'type'), 'Normal'), + + -- ======================================================================== + -- Source fields (from source.*) + -- Identifies which controller/component generated the event + -- ======================================================================== + source_component LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'source', 'component'), ''), + + source_host String MATERIALIZED + coalesce(JSONExtractString(event_json, 'source', 'host'), ''), + + -- ======================================================================== + -- Skip Indexes: Optimized for different query patterns + -- ======================================================================== + + -- Bloom filters for high-cardinality columns used in field selectors + INDEX idx_name_bloom name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_uid_bloom uid TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_involved_name_bloom involved_name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_involved_uid_bloom involved_uid TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_scope_name_bloom scope_name TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Set indexes for low-cardinality columns + INDEX idx_namespace_set namespace TYPE set(100) GRANULARITY 4, + INDEX idx_scope_type_set scope_type TYPE set(10) GRANULARITY 4, + INDEX idx_involved_api_group involved_api_group TYPE set(50) GRANULARITY 4, + INDEX idx_involved_kind_set involved_kind TYPE set(50) GRANULARITY 4, + INDEX idx_reason_set reason TYPE set(100) GRANULARITY 4, + INDEX idx_type_set type TYPE set(10) GRANULARITY 4, + INDEX idx_source_component source_component TYPE set(50) GRANULARITY 4, + + -- Timestamp minmax indexes for time-based queries + INDEX idx_first_timestamp_minmax first_timestamp TYPE minmax GRANULARITY 4, + INDEX idx_last_timestamp_minmax last_timestamp TYPE minmax GRANULARITY 4, + INDEX idx_inserted_at_minmax inserted_at TYPE minmax GRANULARITY 4, + + -- ======================================================================== + -- Projections (defined inline for ReplicatedReplacingMergeTree compatibility) + -- ======================================================================== + + -- Platform-wide queries: sorted by time across all tenants + -- Use cases: "What happened recently across the platform?" + PROJECTION platform_query_projection + ( + SELECT * + ORDER BY (last_timestamp, scope_type, scope_name, involved_api_group, involved_kind, type, uid) + ), + + -- API group / resource queries: sorted by involved object type + -- Use cases: "All events for Deployments", "Events for networking.k8s.io resources" + PROJECTION involved_object_query_projection + ( + SELECT * + ORDER BY (involved_api_group, involved_kind, scope_type, scope_name, last_timestamp, type, uid) + ), + + -- Source component queries: sorted by generating controller/component + -- Use cases: "All events from kubelet", "Events from deployment-controller" + PROJECTION source_query_projection + ( + SELECT * + ORDER BY (source_component, last_timestamp, scope_type, scope_name, involved_api_group, involved_kind, type, uid) + ) + ) + -- ================================================================== + -- TABLE ENGINE CONFIGURATION + -- ================================================================== + -- ReplicatedReplacingMergeTree provides: + -- - Deduplication of true duplicates (same ORDER BY key) during merges + -- - newer inserted_at wins when duplicates are merged + -- - HA replication across database replicas + -- + -- Note: No explicit ZooKeeper path or replica name - the audit database + -- uses the Replicated engine (migration 001) which manages replication + -- paths automatically. Specifying them explicitly is not allowed. + ENGINE = ReplicatedReplacingMergeTree(inserted_at) + PARTITION BY toYYYYMMDD(last_timestamp) + -- Primary key optimized for multi-tenant queries ordered by last_timestamp. + -- Insert-only model: each event state is a separate row, queries deduplicate + -- with LIMIT 1 BY uid. ReplacingMergeTree handles true duplicates from retries. + ORDER BY (scope_type, scope_name, last_timestamp, involved_api_group, involved_kind, type, uid) + PRIMARY KEY (scope_type, scope_name, last_timestamp, involved_api_group, involved_kind, type, uid) + + -- 60-day TTL for event retention (supports EventQuery 60-day window) + TTL last_timestamp + INTERVAL 60 DAY DELETE + + SETTINGS + -- Allow dropping parts during TTL cleanup + ttl_only_drop_parts = 1, + -- Rebuild projections during deduplication merges + deduplicate_merge_projection_mode = 'rebuild'; + + 004_eventsv1_schema_update.sql: | + -- Migration: 004_eventsv1_schema_update + -- Description: Updates k8s_events table schema to support events.k8s.io/v1 Event format. + -- This migration updates MATERIALIZED column expressions and adds new columns for + -- the events.k8s.io/v1 Event structure. + -- Author: Claude Code + -- Date: 2026-02-23 + + -- ============================================================================ + -- events.k8s.io/v1 Event Model + -- ============================================================================ + -- Key differences from core/v1: + -- - eventTime (MicroTime, required) - when event was first observed + -- - series (optional object) - for repeated events: + -- - series.count (int32) - number of occurrences + -- - series.lastObservedTime (MicroTime) - time of last occurrence + -- - regarding (ObjectReference) - replaces involvedObject + -- - related (optional ObjectReference) - secondary object + -- - note (string) - replaces message + -- - action (string, required) - what action was taken + -- - reportingController - replaces source.component + -- - reportingInstance - replaces source.host + -- - Deprecated* fields for backward compat with core/v1 + + -- ============================================================================ + -- Add new columns for events.k8s.io/v1 fields + -- ============================================================================ + + -- Action field (required in v1, describes what action was taken) + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS action LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'action'), ''); + + -- Series count (null/0 for singleton events) + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS series_count Int32 MATERIALIZED + coalesce(JSONExtractInt(event_json, 'series', 'count'), 0); + + -- Series last observed time (for repeated events, defaults to eventTime for singleton events) + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS series_last_observed DateTime64(6) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ); + + -- Is this a singleton event (no series) or part of a series? + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS is_series Bool MATERIALIZED + JSONHas(event_json, 'series'); + + -- Related object (optional secondary object in v1) + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS related_api_version LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'apiVersion'), ''); + + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS related_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'kind'), ''); + + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS related_namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'namespace'), ''); + + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS related_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'name'), ''); + + -- ============================================================================ + -- Update timestamp fields for events.k8s.io/v1 + -- ============================================================================ + -- eventTime is required and uses MicroTime (microsecond precision) + -- series.lastObservedTime is for repeated events + + -- Event time (required in v1, microsecond precision, with fallbacks for compatibility) + ALTER TABLE audit.k8s_events + ADD COLUMN IF NOT EXISTS event_time DateTime64(6) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ); + + -- Update first_timestamp to use eventTime as primary source + ALTER TABLE audit.k8s_events + MODIFY COLUMN first_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ); + + -- Update last_timestamp to use series.lastObservedTime as primary source + ALTER TABLE audit.k8s_events + MODIFY COLUMN last_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ); + + -- ============================================================================ + -- Update involved object fields (involvedObject -> regarding) + -- ============================================================================ + + -- API group extracted from regarding.apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_api_group LowCardinality(String) MATERIALIZED + if( + position(JSONExtractString(event_json, 'regarding', 'apiVersion'), '/') > 0, + splitByChar('/', JSONExtractString(event_json, 'regarding', 'apiVersion'))[1], + '' + ); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_api_version LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'apiVersion'), ''); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'kind'), ''); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'namespace'), ''); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'name'), ''); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN involved_uid String MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'uid'), ''); + + -- ============================================================================ + -- Update source fields (source.* -> reportingController/reportingInstance) + -- ============================================================================ + + ALTER TABLE audit.k8s_events + MODIFY COLUMN source_component LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'reportingController'), ''); + + ALTER TABLE audit.k8s_events + MODIFY COLUMN source_host String MATERIALIZED + coalesce(JSONExtractString(event_json, 'reportingInstance'), ''); + + -- ============================================================================ + -- Add indexes for new columns + -- ============================================================================ + + ALTER TABLE audit.k8s_events + ADD INDEX IF NOT EXISTS idx_action_set action TYPE set(100) GRANULARITY 4; + + ALTER TABLE audit.k8s_events + ADD INDEX IF NOT EXISTS idx_is_series_set is_series TYPE set(2) GRANULARITY 4; + + ALTER TABLE audit.k8s_events + ADD INDEX IF NOT EXISTS idx_event_time_minmax event_time TYPE minmax GRANULARITY 4; + + ALTER TABLE audit.k8s_events + ADD INDEX IF NOT EXISTS idx_series_last_observed_minmax series_last_observed TYPE minmax GRANULARITY 4; + + -- ============================================================================ + -- Migration Complete + -- ============================================================================ + -- The table schema now fully supports events.k8s.io/v1 Event format: + -- - eventTime with microsecond precision + -- - series.count and series.lastObservedTime for repeated events + -- - action field for describing what happened + -- - regarding (renamed from involvedObject) + -- - related (optional secondary object) + -- - reportingController/reportingInstance (renamed from source.*) + -- + -- Column names for involved object remain unchanged for query compatibility. + -- New columns added for v1-specific fields (action, series_*, event_time, related_*). + + 005_activities_reindex_support.sql: | + -- Migration: 005_activities_reindex_support + -- Description: Recreate activities table with origin_id in ORDER BY and version column for deduplication + -- Author: Activity System + -- Date: 2026-02-27 + -- + -- NOTE: This migration drops existing activities data. Activities can be regenerated + -- using the ReindexJob resource to re-process historical audit logs and events. + + -- Step 1: Drop the existing activities table + DROP TABLE IF EXISTS audit.activities; + + -- Step 2: Create new table with updated schema + CREATE TABLE audit.activities + ( + -- Full activity record as JSON (compressed) + activity_json String CODEC(ZSTD(3)), + + -- Version column for ReplacingMergeTree deduplication + -- Newer timestamp wins during merge + reindex_version DateTime64(3) DEFAULT now64(3), + + -- Core timestamp for time-range queries + timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), + now64(3) + ), + + -- Multi-tenant isolation + tenant_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), + + tenant_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), + + -- Origin tracking for correlation to source records + origin_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), + + origin_id String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), + + -- Change source classification (human vs system) + change_source LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), + + -- Actor information + actor_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), + + actor_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), + + actor_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), + + -- Resource information + api_group LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), + + resource_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), + + resource_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), + + resource_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), + + resource_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), + + -- Activity metadata + activity_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), + + activity_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), + + -- Summary for full-text search + summary String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), + + -- ======================================================================== + -- Skip Indexes + -- ======================================================================== + + -- Bloom filter for API group filtering (service provider queries) + INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, + + -- Bloom filter for actor-based filtering + INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Bloom filter for resource lookups + INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Minmax for change source filtering (human vs system) + INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, + + -- Full-text index for summary search (ngrams enable substring/prefix matching) + INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, + + -- ======================================================================== + -- Projections (updated with origin_id) + -- ======================================================================== + + -- Projection for service provider queries (by API group) + PROJECTION api_group_query_projection + ( + SELECT * + ORDER BY (api_group, timestamp, tenant_type, tenant_name, origin_id) + ), + + -- Projection for actor-based queries + PROJECTION actor_query_projection + ( + SELECT * + ORDER BY (actor_name, timestamp, tenant_type, tenant_name, origin_id) + ) + ) + ENGINE = ReplicatedReplacingMergeTree(reindex_version) + PARTITION BY toYYYYMMDD(timestamp) + -- Updated ORDER BY with origin_id for deduplication. + -- NOTE: The combination (tenant_type, tenant_name, timestamp, origin_id) is unique because + -- the activity processor follows a "first policy wins" rule - each source event (audit log + -- or Kubernetes Event) produces at most one Activity. Even if multiple ActivityPolicies + -- match the same event, only the first matching policy generates an Activity. + -- See evaluateBatch() in internal/reindex/evaluate.go. + ORDER BY (tenant_type, tenant_name, timestamp, origin_id) + PRIMARY KEY (tenant_type, tenant_name, timestamp, origin_id) + + -- 60-day retention for activities + TTL timestamp + INTERVAL 60 DAY DELETE + + SETTINGS + storage_policy = 'default', + ttl_only_drop_parts = 1, + deduplicate_merge_projection_mode = 'rebuild'; + + 006_rename_involved_to_regarding.sql: | + -- Migration: 006_rename_involved_to_regarding + -- Description: Renames involved_* columns to regarding_* to align with events.k8s.io/v1 canonical naming. + -- Requires table recreation because columns are part of ORDER BY key and projections. + -- Author: Claude Code + -- Date: 2026-03-04 + + -- ============================================================================ + -- Strategy: Create new table, copy data, swap tables + -- ============================================================================ + -- ClickHouse doesn't allow renaming columns that are part of the sorting key + -- (ORDER BY / PRIMARY KEY). We must recreate the table with the new schema. + -- + -- Steps: + -- 1. Create k8s_events_new with regarding_* column names + -- 2. Insert all data from k8s_events (columns auto-populated from event_json) + -- 3. Drop original k8s_events table + -- 4. Rename k8s_events_new to k8s_events + + -- ============================================================================ + -- Step 1: Create new table with regarding_* column names + -- ============================================================================ + CREATE TABLE IF NOT EXISTS audit.k8s_events_new + ( + -- Raw event JSON (events.k8s.io/v1.Event) + event_json String CODEC(ZSTD(3)), + + -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) + inserted_at DateTime64(9) DEFAULT now64(9), + + -- ======================================================================== + -- Multi-tenant scope (primary query dimension) + -- ======================================================================== + scope_type LowCardinality(String) MATERIALIZED + coalesce( + JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.type'), + '' + ), + + scope_name String MATERIALIZED + coalesce( + JSONExtractString(event_json, 'metadata', 'annotations', 'platform.miloapis.com/scope.name'), + '' + ), + + -- ======================================================================== + -- Timestamp fields + -- ======================================================================== + first_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + last_timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + event_time DateTime64(6) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedFirstTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + -- ======================================================================== + -- Metadata fields (from metadata.*) + -- ======================================================================== + namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'namespace'), ''), + + name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'name'), ''), + + uid String MATERIALIZED + coalesce(JSONExtractString(event_json, 'metadata', 'uid'), ''), + + -- ======================================================================== + -- Regarding Object fields (from regarding.* - events.k8s.io/v1) + -- ======================================================================== + -- API group extracted from apiVersion (e.g., "apps/v1" -> "apps", "v1" -> "") + regarding_api_group LowCardinality(String) MATERIALIZED + if( + position(JSONExtractString(event_json, 'regarding', 'apiVersion'), '/') > 0, + splitByChar('/', JSONExtractString(event_json, 'regarding', 'apiVersion'))[1], + '' + ), + + regarding_api_version LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'apiVersion'), ''), + + regarding_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'kind'), ''), + + regarding_namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'namespace'), ''), + + regarding_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'name'), ''), + + regarding_uid String MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'uid'), ''), + + regarding_field_path String MATERIALIZED + coalesce(JSONExtractString(event_json, 'regarding', 'fieldPath'), ''), + + -- ======================================================================== + -- Related Object fields (optional secondary object in events.k8s.io/v1) + -- ======================================================================== + related_api_version LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'apiVersion'), ''), + + related_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'kind'), ''), + + related_namespace LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'namespace'), ''), + + related_name String MATERIALIZED + coalesce(JSONExtractString(event_json, 'related', 'name'), ''), + + -- ======================================================================== + -- Event classification fields + -- ======================================================================== + reason LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'reason'), ''), + + -- Type is "Normal" or "Warning" + type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'type'), 'Normal'), + + -- Action field (required in v1, describes what action was taken) + action LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'action'), ''), + + -- ======================================================================== + -- Series fields (for repeated events in events.k8s.io/v1) + -- ======================================================================== + series_count Int32 MATERIALIZED + coalesce(JSONExtractInt(event_json, 'series', 'count'), 0), + + series_last_observed DateTime64(6) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'series', 'lastObservedTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'eventTime')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'deprecatedLastTimestamp')), + parseDateTime64BestEffortOrNull(JSONExtractString(event_json, 'metadata', 'creationTimestamp')) + ), + + is_series Bool MATERIALIZED + JSONHas(event_json, 'series'), + + -- ======================================================================== + -- Source fields (reportingController/reportingInstance in events.k8s.io/v1) + -- ======================================================================== + source_component LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(event_json, 'reportingController'), ''), + + source_host String MATERIALIZED + coalesce(JSONExtractString(event_json, 'reportingInstance'), ''), + + -- ======================================================================== + -- Skip Indexes + -- ======================================================================== + -- Bloom filters for high-cardinality columns + INDEX idx_name_bloom name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_uid_bloom uid TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_regarding_name_bloom regarding_name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_regarding_uid_bloom regarding_uid TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_scope_name_bloom scope_name TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Set indexes for low-cardinality columns + INDEX idx_namespace_set namespace TYPE set(100) GRANULARITY 4, + INDEX idx_scope_type_set scope_type TYPE set(10) GRANULARITY 4, + INDEX idx_regarding_api_group regarding_api_group TYPE set(50) GRANULARITY 4, + INDEX idx_regarding_kind_set regarding_kind TYPE set(50) GRANULARITY 4, + INDEX idx_reason_set reason TYPE set(100) GRANULARITY 4, + INDEX idx_type_set type TYPE set(10) GRANULARITY 4, + INDEX idx_source_component source_component TYPE set(50) GRANULARITY 4, + INDEX idx_action_set action TYPE set(100) GRANULARITY 4, + INDEX idx_is_series_set is_series TYPE set(2) GRANULARITY 4, + + -- Timestamp minmax indexes + INDEX idx_first_timestamp_minmax first_timestamp TYPE minmax GRANULARITY 4, + INDEX idx_last_timestamp_minmax last_timestamp TYPE minmax GRANULARITY 4, + INDEX idx_inserted_at_minmax inserted_at TYPE minmax GRANULARITY 4, + INDEX idx_event_time_minmax event_time TYPE minmax GRANULARITY 4, + INDEX idx_series_last_observed_minmax series_last_observed TYPE minmax GRANULARITY 4, + + -- ======================================================================== + -- Projections (using regarding_* column names) + -- ======================================================================== + + -- Platform-wide queries: sorted by time across all tenants + PROJECTION platform_query_projection + ( + SELECT * + ORDER BY (last_timestamp, scope_type, scope_name, regarding_api_group, regarding_kind, type, uid) + ), + + -- API group / resource queries: sorted by regarding object type + PROJECTION regarding_object_query_projection + ( + SELECT * + ORDER BY (regarding_api_group, regarding_kind, scope_type, scope_name, last_timestamp, type, uid) + ), + + -- Source component queries: sorted by generating controller/component + PROJECTION source_query_projection + ( + SELECT * + ORDER BY (source_component, last_timestamp, scope_type, scope_name, regarding_api_group, regarding_kind, type, uid) + ) + ) + ENGINE = ReplicatedReplacingMergeTree(inserted_at) + PARTITION BY toYYYYMMDD(last_timestamp) + ORDER BY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) + PRIMARY KEY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) + TTL last_timestamp + INTERVAL 60 DAY DELETE + SETTINGS + ttl_only_drop_parts = 1, + deduplicate_merge_projection_mode = 'rebuild'; + + -- ============================================================================ + -- Step 2: Copy data from old table to new table + -- ============================================================================ + -- Only copy event_json and inserted_at - MATERIALIZED columns auto-populate + INSERT INTO audit.k8s_events_new (event_json, inserted_at) + SELECT event_json, inserted_at + FROM audit.k8s_events; + + -- ============================================================================ + -- Step 3: Swap tables + -- ============================================================================ + -- Drop old table + DROP TABLE IF EXISTS audit.k8s_events; + + -- Rename new table to original name + RENAME TABLE audit.k8s_events_new TO audit.k8s_events; + + -- ============================================================================ + -- Migration Complete + -- ============================================================================ + -- The k8s_events table now uses regarding_* column names throughout: + -- - regarding_api_group, regarding_api_version, regarding_kind + -- - regarding_namespace, regarding_name, regarding_uid, regarding_field_path + -- - ORDER BY and PRIMARY KEY use regarding_api_group, regarding_kind + -- - Projections renamed to use regarding_* columns + -- - Indexes renamed to idx_regarding_* + -- + -- Storage layer code (Go) must use regarding_* column names in queries. + + 007_activities_table_v2.sql: | + -- Migration: 007_activities_table_v2 + -- Description: Recreate activities table with time-bucketed projections aligned with audit_logs patterns + -- Author: Activity System + -- Date: 2026-03-10 + -- + -- NOTE: This migration drops existing activities data. Activities can be regenerated + -- using the ReindexJob resource to re-process historical audit logs and events. + + -- Step 1: Drop the existing activities table + DROP TABLE IF EXISTS audit.activities; + + -- Step 2: Create new table with updated schema and time-bucketed projections + CREATE TABLE audit.activities + ( + -- Full activity record as JSON (compressed) + activity_json String CODEC(ZSTD(3)), + + -- Version column for ReplacingMergeTree deduplication + -- Newer timestamp wins during merge + reindex_version DateTime64(3) DEFAULT now64(3), + + -- Core timestamp for time-range queries + timestamp DateTime64(3) MATERIALIZED + coalesce( + parseDateTime64BestEffortOrNull(JSONExtractString(activity_json, 'metadata', 'creationTimestamp')), + now64(3) + ), + + -- Multi-tenant isolation + tenant_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'type'), ''), + + tenant_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'tenant', 'name'), ''), + + -- Origin tracking for correlation to source records + origin_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'type'), ''), + + origin_id String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'origin', 'id'), ''), + + -- Change source classification (human vs system) + change_source LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'changeSource'), ''), + + -- Actor information + actor_type LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'type'), ''), + + actor_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'name'), ''), + + actor_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'actor', 'uid'), ''), + + -- Resource information + api_group LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'apiGroup'), ''), + + resource_kind LowCardinality(String) MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'kind'), ''), + + resource_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'name'), ''), + + resource_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'namespace'), ''), + + resource_uid String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'resource', 'uid'), ''), + + -- Activity metadata + activity_name String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'name'), ''), + + activity_namespace String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'metadata', 'namespace'), ''), + + -- Summary for full-text search + summary String MATERIALIZED + coalesce(JSONExtractString(activity_json, 'spec', 'summary'), ''), + + -- ======================================================================== + -- Skip Indexes + -- ======================================================================== + + -- Bloom filter for API group filtering (service provider queries) + INDEX idx_api_group api_group TYPE bloom_filter(0.01) GRANULARITY 1, + + -- Bloom filter for actor-based filtering + INDEX idx_actor_name actor_name TYPE bloom_filter(0.001) GRANULARITY 1, + INDEX idx_actor_uid actor_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Bloom filter for resource lookups + INDEX idx_resource resource_kind TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_name resource_name TYPE bloom_filter(0.01) GRANULARITY 1, + INDEX idx_resource_uid resource_uid TYPE bloom_filter(0.001) GRANULARITY 1, + + -- Minmax for change source filtering (human vs system) + INDEX idx_change_source change_source TYPE set(10) GRANULARITY 4, + + -- Full-text index for summary search (ngrams enable substring/prefix matching) + INDEX idx_summary_search summary TYPE text(tokenizer = ngrams(3)) GRANULARITY 1, + + -- ======================================================================== + -- Projections (time-bucketed, aligned with audit_logs patterns) + -- ======================================================================== + + -- Projection for platform-wide queries by API group + PROJECTION platform_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, api_group, resource_kind, resource_uid) + ), + + -- Projection for actor-name-based queries (platform-wide) + PROJECTION actor_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, actor_name, api_group, resource_kind, resource_uid) + ), + + -- Projection for actor-uid-based queries (user scope) + PROJECTION actor_uid_query_projection + ( + SELECT * + ORDER BY (toStartOfHour(timestamp), timestamp, actor_uid, api_group, resource_kind, resource_uid) + ) + ) + ENGINE = ReplicatedReplacingMergeTree(reindex_version) + PARTITION BY toYYYYMMDD(timestamp) + -- Primary key uses toStartOfHour(timestamp) as the leading column for efficient + -- time-bucketed queries, matching the projection sort orders. + -- origin_id provides deduplication: the activity processor follows a + -- "first policy wins" rule so each source event produces at most one Activity. + ORDER BY (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) + PRIMARY KEY (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) + + -- 60-day retention for activities + TTL timestamp + INTERVAL 60 DAY DELETE + + SETTINGS + storage_policy = 'default', + ttl_only_drop_parts = 1, + deduplicate_merge_projection_mode = 'rebuild'; + diff --git a/config/components/k8s-event-exporter/deployment.yaml b/config/components/k8s-event-exporter/deployment.yaml index bfcc9921..d6a9f02e 100644 --- a/config/components/k8s-event-exporter/deployment.yaml +++ b/config/components/k8s-event-exporter/deployment.yaml @@ -1,84 +1,84 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: k8s-event-exporter - namespace: activity-system - labels: - app: k8s-event-exporter - app.kubernetes.io/name: k8s-event-exporter - app.kubernetes.io/component: event-forwarder -spec: - replicas: 1 - selector: - matchLabels: - app: k8s-event-exporter - template: - metadata: - labels: - app: k8s-event-exporter - app.kubernetes.io/name: k8s-event-exporter - app.kubernetes.io/component: event-forwarder - spec: - serviceAccountName: k8s-event-exporter - securityContext: - runAsNonRoot: true - runAsUser: 65532 - fsGroup: 65532 - containers: - - name: event-exporter - # Uses the same activity image with the event-exporter subcommand - image: ghcr.io/datum-cloud/activity:dev - imagePullPolicy: IfNotPresent - args: - - event-exporter - - --logging-format=$(LOGGING_FORMAT) - - -v=2 - ports: - - containerPort: 8081 - name: health - protocol: TCP - env: - - name: LOGGING_FORMAT - value: "json" - - name: NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - - name: SUBJECT_PREFIX - value: "events" - # SCOPE_TYPE and SCOPE_NAME provide default scope annotations for bootstrapping. - # In production, Milo injects these values at request time via admission webhook. - # Override these defaults per-environment using Kustomize patches. - - name: SCOPE_TYPE - value: "organization" - - name: SCOPE_NAME - value: "dev-org" - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: true - capabilities: - drop: - - ALL - resources: - requests: - cpu: 10m - memory: 32Mi - limits: - cpu: 100m - memory: 128Mi - livenessProbe: - httpGet: - path: /healthz - port: health - scheme: HTTP - initialDelaySeconds: 15 - periodSeconds: 20 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - httpGet: - path: /readyz - port: health - scheme: HTTP - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 3 +apiVersion: apps/v1 +kind: Deployment +metadata: + name: k8s-event-exporter + namespace: activity-system + labels: + app: k8s-event-exporter + app.kubernetes.io/name: k8s-event-exporter + app.kubernetes.io/component: event-forwarder +spec: + replicas: 1 + selector: + matchLabels: + app: k8s-event-exporter + template: + metadata: + labels: + app: k8s-event-exporter + app.kubernetes.io/name: k8s-event-exporter + app.kubernetes.io/component: event-forwarder + spec: + serviceAccountName: k8s-event-exporter + securityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + containers: + - name: event-exporter + # Uses the same activity image with the event-exporter subcommand + image: ghcr.io/datum-cloud/activity:dev + imagePullPolicy: IfNotPresent + args: + - event-exporter + - --logging-format=$(LOGGING_FORMAT) + - -v=2 + ports: + - containerPort: 8081 + name: health + protocol: TCP + env: + - name: LOGGING_FORMAT + value: "json" + - name: NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + - name: SUBJECT_PREFIX + value: "events" + # SCOPE_TYPE and SCOPE_NAME provide default scope annotations for bootstrapping. + # In production, Milo injects these values at request time via admission webhook. + # Override these defaults per-environment using Kustomize patches. + - name: SCOPE_TYPE + value: "organization" + - name: SCOPE_NAME + value: "dev-org" + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + resources: + requests: + cpu: 10m + memory: 32Mi + limits: + cpu: 100m + memory: 128Mi + livenessProbe: + httpGet: + path: /healthz + port: health + scheme: HTTP + initialDelaySeconds: 15 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: health + scheme: HTTP + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 diff --git a/config/components/nats-streams/dlq-stream.yaml b/config/components/nats-streams/dlq-stream.yaml index 4de11311..574444cf 100644 --- a/config/components/nats-streams/dlq-stream.yaml +++ b/config/components/nats-streams/dlq-stream.yaml @@ -1,76 +1,76 @@ -# Dead-Letter Queue Stream for failed activity events. -# -# This stream captures events that failed processing due to CEL evaluation errors, -# template rendering failures, or other issues. Events are preserved for debugging -# while allowing processing to continue. -# -# CONSUMING FROM THE DLQ: -# -# 1. View recent dead-lettered events: -# nats stream view ACTIVITY_DEAD_LETTER --last 10 -# -# 2. Create a temporary consumer to filter by event type: -# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.audit.>" -# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.k8s-event.>" -# -# 3. Filter by API group and kind: -# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.audit.apps.Deployment" -# -# 4. Replay events after fixing policy: -# - Extract originalPayload from DLQ message -# - Republish to AUDIT_EVENTS or EVENTS stream -# - Event will be reprocessed with fixed policy -# -# 5. Purge old messages: -# nats stream purge ACTIVITY_DEAD_LETTER --keep 0 --filter ">" --older 168h -# ---- -apiVersion: jetstream.nats.io/v1beta2 -kind: Stream -metadata: - name: activity-dead-letter -spec: - # Stream name in NATS - name: ACTIVITY_DEAD_LETTER - - # Subjects to consume - wildcard for all DLQ events - # Format: activity.dlq... - subjects: - - activity.dlq.> - - # Retention policy: limits-based (time + size) - retention: limits - - # Storage: file-based for durability - storage: file - - # Maximum age: 30 days (longer retention for debugging) - maxAge: 720h # 30 days = 720 hours - - # Maximum bytes: 1GB (sufficient for dev/test environments) - # Production should increase to 10GB for longer debugging cycles - maxBytes: 1073741824 # 1 * 1024 * 1024 * 1024 - - # Number of replicas for high availability - replicas: 1 # Increase to 3 for production HA - - # Discard policy when limits are reached - discard: old - - # Allow direct access for queries - allowDirect: true - - # Deduplication window - duplicateWindow: 5m - - # Maximum number of consumers - maxConsumers: 10 - - # Maximum message size (1MB - sufficient for audit events + metadata) - maxMsgSize: 1048576 # 1 * 1024 * 1024 - - # No message limit - rely on age and size limits - maxMsgs: -1 - - # Performance tuning - noAck: false # Require acknowledgments for durability +# Dead-Letter Queue Stream for failed activity events. +# +# This stream captures events that failed processing due to CEL evaluation errors, +# template rendering failures, or other issues. Events are preserved for debugging +# while allowing processing to continue. +# +# CONSUMING FROM THE DLQ: +# +# 1. View recent dead-lettered events: +# nats stream view ACTIVITY_DEAD_LETTER --last 10 +# +# 2. Create a temporary consumer to filter by event type: +# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.audit.>" +# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.k8s-event.>" +# +# 3. Filter by API group and kind: +# nats consumer add ACTIVITY_DEAD_LETTER temp --filter "activity.dlq.audit.apps.Deployment" +# +# 4. Replay events after fixing policy: +# - Extract originalPayload from DLQ message +# - Republish to AUDIT_EVENTS or EVENTS stream +# - Event will be reprocessed with fixed policy +# +# 5. Purge old messages: +# nats stream purge ACTIVITY_DEAD_LETTER --keep 0 --filter ">" --older 168h +# +--- +apiVersion: jetstream.nats.io/v1beta2 +kind: Stream +metadata: + name: activity-dead-letter +spec: + # Stream name in NATS + name: ACTIVITY_DEAD_LETTER + + # Subjects to consume - wildcard for all DLQ events + # Format: activity.dlq... + subjects: + - activity.dlq.> + + # Retention policy: limits-based (time + size) + retention: limits + + # Storage: file-based for durability + storage: file + + # Maximum age: 30 days (longer retention for debugging) + maxAge: 720h # 30 days = 720 hours + + # Maximum bytes: 1GB (sufficient for dev/test environments) + # Production should increase to 10GB for longer debugging cycles + maxBytes: 1073741824 # 1 * 1024 * 1024 * 1024 + + # Number of replicas for high availability + replicas: 1 # Increase to 3 for production HA + + # Discard policy when limits are reached + discard: old + + # Allow direct access for queries + allowDirect: true + + # Deduplication window + duplicateWindow: 5m + + # Maximum number of consumers + maxConsumers: 10 + + # Maximum message size (1MB - sufficient for audit events + metadata) + maxMsgSize: 1048576 # 1 * 1024 * 1024 + + # No message limit - rely on age and size limits + maxMsgs: -1 + + # Performance tuning + noAck: false # Require acknowledgments for durability diff --git a/config/components/observability/alerts/activity-alerts.yaml b/config/components/observability/alerts/activity-alerts.yaml index 02d5928f..7ae9b530 100644 --- a/config/components/observability/alerts/activity-alerts.yaml +++ b/config/components/observability/alerts/activity-alerts.yaml @@ -1,238 +1,238 @@ -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: activity-alerts - namespace: activity-system - labels: - prometheus: activity - app.kubernetes.io/part-of: activity - monitoring: "true" -spec: - groups: - # ========================================================================= - # Key SLI Alerts - User-Facing Service Quality - # ========================================================================= - - name: activity-sli - interval: 30s - rules: - # Service Availability SLI - - alert: ActivityAPIServerDown - expr: up{job="activity-apiserver"} == 0 - for: 5m - labels: - severity: critical - component: activity-apiserver - sli: availability - annotations: - summary: "Activity is unavailable" - description: "Activity has been down for more than 5 minutes. Users cannot query audit logs." - impact: "Complete service outage - no audit log queries possible" - - # Request Success Rate SLI (Error Budget) - - alert: ActivityHighErrorRate - expr: | - sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) - / - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - > 0.01 - for: 10m - labels: - severity: warning - component: activity-apiserver - sli: success_rate - annotations: - summary: "High error rate in Activity" - description: "{{ $value | humanizePercentage }} of requests are failing (target: <1%)" - impact: "Users experiencing failed audit log queries" - - # Query Latency SLI - Most Critical for User Experience - - alert: ActivityQueryLatencyHigh - expr: | - histogram_quantile(0.99, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) > 10 - for: 10m - labels: - severity: warning - component: activity-apiserver - sli: latency - annotations: - summary: "Audit log queries are slow" - description: "p99 query latency is {{ $value }}s (target: <10s). Users experiencing slow responses." - impact: "Degraded user experience - queries taking too long" - - # Data Availability SLI - Backend Health - - alert: ActivityClickHouseUnavailable - expr: | - rate(activity_clickhouse_query_errors_total{error_type="connection"}[5m]) > 0.1 - for: 5m - labels: - severity: critical - component: clickhouse - sli: availability - annotations: - summary: "ClickHouse database is unavailable" - description: "Cannot connect to ClickHouse ({{ $value }} errors/sec). Audit log data is inaccessible." - impact: "Complete service degradation - no data can be retrieved" - - # ========================================================================= - # Data Pipeline Health - Ensures Fresh Data - # ========================================================================= - - name: activity-pipeline - interval: 30s - rules: - # Data Freshness SLI - Critical for audit compliance - - alert: ActivityDataPipelineStalled - expr: | - rate(vector_events_out_total{component_id="clickhouse"}[5m]) == 0 - for: 15m - labels: - severity: critical - component: vector-aggregator - sli: data_freshness - annotations: - summary: "Audit event pipeline has stalled" - description: "No new audit events are being stored in ClickHouse. Data is becoming stale." - impact: "Users querying outdated audit data - compliance risk" - - # NATS Consumer Lag - Leading indicator of pipeline issues - - alert: ActivityPipelineBacklogCritical - expr: | - nats_jetstream_consumer_num_pending{stream="AUDIT_EVENTS"} > 500000 - for: 10m - labels: - severity: critical - component: nats - sli: data_freshness - annotations: - summary: "Audit event backlog is critical" - description: "{{ $value }} audit events pending. Risk of data loss if retention exceeded." - impact: "Large delay in audit event availability - potential data loss" - - # Event Exporter Availability - - alert: EventExporterDown - expr: up{job="k8s-event-exporter"} == 0 - for: 5m - labels: - severity: warning - component: k8s-event-exporter - annotations: - summary: "Kubernetes event exporter is unavailable" - description: "Event exporter has been down for more than 5 minutes. Kubernetes events are not being published to NATS." - impact: "Cluster events not being captured - event timeline will have gaps" - - # Event Exporter Publish Errors - - alert: EventExporterPublishErrors - expr: rate(event_exporter_publish_errors_total{job="k8s-event-exporter"}[5m]) > 0.1 - for: 10m - labels: - severity: warning - component: k8s-event-exporter - annotations: - summary: "Event exporter experiencing publish errors" - description: "{{ $value }} publish errors/sec for 10+ minutes. Events may not reach NATS." - impact: "Some cluster events missing from timeline" - - # ========================================================================= - # Activity Processor Health - Translation Engine - # ========================================================================= - - name: activity-processor - interval: 30s - rules: - # Processor Availability - - alert: ActivityProcessorDown - expr: up{job="activity-processor"} == 0 - for: 5m - labels: - severity: critical - component: activity-processor - annotations: - summary: "Activity processor is unavailable" - description: "Activity processor has been down for more than 5 minutes. No new activities are being generated." - impact: "Complete activity generation outage - audit events not translated to activities" - - # NATS Connection Health - - alert: ActivityProcessorNATSDisconnected - expr: activity_processor_nats_connection_status == 0 - for: 2m - labels: - severity: critical - component: activity-processor - annotations: - summary: "Activity processor disconnected from NATS" - description: "Processor has lost NATS connection. Cannot receive events." - impact: "Activity generation stopped - events not being processed" - - # Activity Generation Stalled - - alert: ActivityGenerationStalled - expr: | - rate(activity_processor_activities_generated_total[5m]) == 0 - AND - rate(activity_processor_audit_events_received_total[5m]) > 0 - for: 10m - labels: - severity: critical - component: activity-processor - annotations: - summary: "Activity generation has stalled" - description: "Processor receiving events but not generating activities for 10+ minutes." - impact: "Activity timeline not updating - users see stale data" - - # Error Rate Threshold - - alert: ActivityProcessorHighErrorRate - expr: | - sum(rate(activity_processor_audit_events_errored_total[5m])) - / - sum(rate(activity_processor_audit_events_received_total[5m])) - > 0.05 - for: 10m - labels: - severity: warning - component: activity-processor - annotations: - summary: "High error rate in activity processor" - description: "{{ $value | humanizePercentage }} of events failing to process (target: <5%)" - impact: "Some activities missing from timeline - potential data gaps" - - # Policy Cache Health - - alert: ActivityProcessorNoPolicies - expr: activity_processor_active_policies == 0 - for: 15m - labels: - severity: warning - component: activity-processor - annotations: - summary: "Activity processor has no active policies" - description: "Policy cache is empty. Events will not be translated to activities." - impact: "No activities being generated - timeline will be empty" - - # ========================================================================= - # Activity Controller Manager - Policy Lifecycle - # ========================================================================= - - name: activity-controller - interval: 30s - rules: - # Policy Validation Failures - - alert: ActivityPolicyValidationFailing - expr: sum(rate(activity_controller_policy_validation_errors_total[10m])) > 0.1 - for: 15m - labels: - severity: warning - component: activity-controller-manager - annotations: - summary: "ActivityPolicy validation failures occurring" - description: "{{ $value }} policy validation errors/sec. Policies may be misconfigured." - impact: "Some policies may not be active - activity generation could be incomplete" - - # Policy Readiness - - alert: ActivityPolicyNotReady - expr: count by (policy) (activity_controller_policy_ready == 0) > 0 - for: 30m - labels: - severity: warning - component: activity-controller-manager - annotations: - summary: "ActivityPolicies not ready" - description: "{{ $value }} ActivityPolicy resources not ready for 30+ minutes." - impact: "Affected policies not being used by processor - activity generation incomplete" +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: activity-alerts + namespace: activity-system + labels: + prometheus: activity + app.kubernetes.io/part-of: activity + monitoring: "true" +spec: + groups: + # ========================================================================= + # Key SLI Alerts - User-Facing Service Quality + # ========================================================================= + - name: activity-sli + interval: 30s + rules: + # Service Availability SLI + - alert: ActivityAPIServerDown + expr: up{job="activity-apiserver"} == 0 + for: 5m + labels: + severity: critical + component: activity-apiserver + sli: availability + annotations: + summary: "Activity is unavailable" + description: "Activity has been down for more than 5 minutes. Users cannot query audit logs." + impact: "Complete service outage - no audit log queries possible" + + # Request Success Rate SLI (Error Budget) + - alert: ActivityHighErrorRate + expr: | + sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) + / + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + > 0.01 + for: 10m + labels: + severity: warning + component: activity-apiserver + sli: success_rate + annotations: + summary: "High error rate in Activity" + description: "{{ $value | humanizePercentage }} of requests are failing (target: <1%)" + impact: "Users experiencing failed audit log queries" + + # Query Latency SLI - Most Critical for User Experience + - alert: ActivityQueryLatencyHigh + expr: | + histogram_quantile(0.99, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) > 10 + for: 10m + labels: + severity: warning + component: activity-apiserver + sli: latency + annotations: + summary: "Audit log queries are slow" + description: "p99 query latency is {{ $value }}s (target: <10s). Users experiencing slow responses." + impact: "Degraded user experience - queries taking too long" + + # Data Availability SLI - Backend Health + - alert: ActivityClickHouseUnavailable + expr: | + rate(activity_clickhouse_query_errors_total{error_type="connection"}[5m]) > 0.1 + for: 5m + labels: + severity: critical + component: clickhouse + sli: availability + annotations: + summary: "ClickHouse database is unavailable" + description: "Cannot connect to ClickHouse ({{ $value }} errors/sec). Audit log data is inaccessible." + impact: "Complete service degradation - no data can be retrieved" + + # ========================================================================= + # Data Pipeline Health - Ensures Fresh Data + # ========================================================================= + - name: activity-pipeline + interval: 30s + rules: + # Data Freshness SLI - Critical for audit compliance + - alert: ActivityDataPipelineStalled + expr: | + rate(vector_events_out_total{component_id="clickhouse"}[5m]) == 0 + for: 15m + labels: + severity: critical + component: vector-aggregator + sli: data_freshness + annotations: + summary: "Audit event pipeline has stalled" + description: "No new audit events are being stored in ClickHouse. Data is becoming stale." + impact: "Users querying outdated audit data - compliance risk" + + # NATS Consumer Lag - Leading indicator of pipeline issues + - alert: ActivityPipelineBacklogCritical + expr: | + nats_jetstream_consumer_num_pending{stream="AUDIT_EVENTS"} > 500000 + for: 10m + labels: + severity: critical + component: nats + sli: data_freshness + annotations: + summary: "Audit event backlog is critical" + description: "{{ $value }} audit events pending. Risk of data loss if retention exceeded." + impact: "Large delay in audit event availability - potential data loss" + + # Event Exporter Availability + - alert: EventExporterDown + expr: up{job="k8s-event-exporter"} == 0 + for: 5m + labels: + severity: warning + component: k8s-event-exporter + annotations: + summary: "Kubernetes event exporter is unavailable" + description: "Event exporter has been down for more than 5 minutes. Kubernetes events are not being published to NATS." + impact: "Cluster events not being captured - event timeline will have gaps" + + # Event Exporter Publish Errors + - alert: EventExporterPublishErrors + expr: rate(event_exporter_publish_errors_total{job="k8s-event-exporter"}[5m]) > 0.1 + for: 10m + labels: + severity: warning + component: k8s-event-exporter + annotations: + summary: "Event exporter experiencing publish errors" + description: "{{ $value }} publish errors/sec for 10+ minutes. Events may not reach NATS." + impact: "Some cluster events missing from timeline" + + # ========================================================================= + # Activity Processor Health - Translation Engine + # ========================================================================= + - name: activity-processor + interval: 30s + rules: + # Processor Availability + - alert: ActivityProcessorDown + expr: up{job="activity-processor"} == 0 + for: 5m + labels: + severity: critical + component: activity-processor + annotations: + summary: "Activity processor is unavailable" + description: "Activity processor has been down for more than 5 minutes. No new activities are being generated." + impact: "Complete activity generation outage - audit events not translated to activities" + + # NATS Connection Health + - alert: ActivityProcessorNATSDisconnected + expr: activity_processor_nats_connection_status == 0 + for: 2m + labels: + severity: critical + component: activity-processor + annotations: + summary: "Activity processor disconnected from NATS" + description: "Processor has lost NATS connection. Cannot receive events." + impact: "Activity generation stopped - events not being processed" + + # Activity Generation Stalled + - alert: ActivityGenerationStalled + expr: | + rate(activity_processor_activities_generated_total[5m]) == 0 + AND + rate(activity_processor_audit_events_received_total[5m]) > 0 + for: 10m + labels: + severity: critical + component: activity-processor + annotations: + summary: "Activity generation has stalled" + description: "Processor receiving events but not generating activities for 10+ minutes." + impact: "Activity timeline not updating - users see stale data" + + # Error Rate Threshold + - alert: ActivityProcessorHighErrorRate + expr: | + sum(rate(activity_processor_audit_events_errored_total[5m])) + / + sum(rate(activity_processor_audit_events_received_total[5m])) + > 0.05 + for: 10m + labels: + severity: warning + component: activity-processor + annotations: + summary: "High error rate in activity processor" + description: "{{ $value | humanizePercentage }} of events failing to process (target: <5%)" + impact: "Some activities missing from timeline - potential data gaps" + + # Policy Cache Health + - alert: ActivityProcessorNoPolicies + expr: activity_processor_active_policies == 0 + for: 15m + labels: + severity: warning + component: activity-processor + annotations: + summary: "Activity processor has no active policies" + description: "Policy cache is empty. Events will not be translated to activities." + impact: "No activities being generated - timeline will be empty" + + # ========================================================================= + # Activity Controller Manager - Policy Lifecycle + # ========================================================================= + - name: activity-controller + interval: 30s + rules: + # Policy Validation Failures + - alert: ActivityPolicyValidationFailing + expr: sum(rate(activity_controller_policy_validation_errors_total[10m])) > 0.1 + for: 15m + labels: + severity: warning + component: activity-controller-manager + annotations: + summary: "ActivityPolicy validation failures occurring" + description: "{{ $value }} policy validation errors/sec. Policies may be misconfigured." + impact: "Some policies may not be active - activity generation could be incomplete" + + # Policy Readiness + - alert: ActivityPolicyNotReady + expr: count by (policy) (activity_controller_policy_ready == 0) > 0 + for: 30m + labels: + severity: warning + component: activity-controller-manager + annotations: + summary: "ActivityPolicies not ready" + description: "{{ $value }} ActivityPolicy resources not ready for 30+ minutes." + impact: "Affected policies not being used by processor - activity generation incomplete" diff --git a/config/components/observability/alerts/dlq-alerts.yaml b/config/components/observability/alerts/dlq-alerts.yaml index ac5b0fbd..cef3aa04 100644 --- a/config/components/observability/alerts/dlq-alerts.yaml +++ b/config/components/observability/alerts/dlq-alerts.yaml @@ -1,109 +1,109 @@ -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: dlq-alerts - namespace: activity-system - labels: - prometheus: activity - app.kubernetes.io/part-of: activity - monitoring: "true" -spec: - groups: - # ========================================================================= - # DLQ Health Alerts - Platform SRE Ownership - # ========================================================================= - - name: dlq-health - interval: 30s - rules: - # DLQ Growing - Queue Not Draining - - alert: DLQQueueGrowing - expr: | - rate(activity_processor_dlq_events_published_total[5m]) > 0.1 - AND - increase(activity_processor_dlq_events_published_total[15m]) > 100 - for: 15m - labels: - severity: warning - component: activity-processor - team: platform-sre - annotations: - summary: "DLQ is growing - messages not being processed" - description: "DLQ is receiving {{ $value }} events/sec for 15+ minutes. Events are failing processing faster than retry can handle." - impact: "Failed events accumulating - may indicate systematic policy issue or processor problem" - runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-growth.md" - - # DLQ Publish Errors - - alert: DLQPublishErrors - expr: rate(activity_processor_dlq_publish_errors_total[5m]) > 0.1 - for: 10m - labels: - severity: warning - component: activity-processor - team: platform-sre - annotations: - summary: "Failed to publish events to DLQ" - description: "{{ $value }} DLQ publish errors/sec. Events may be lost entirely." - impact: "Failed events not being captured - complete data loss for affected events" - runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-publish-errors.md" - - # High Retry Count Events - - alert: DLQHighRetryCount - expr: | - increase(activity_processor_dlq_retry_events_high_retry_total[1h]) > 10 - for: 1h - labels: - severity: warning - component: activity-processor - team: platform-sre - annotations: - summary: "DLQ events with excessive retry attempts" - description: "{{ $value }} events have exceeded the high retry threshold for {{ $labels.api_group }}/{{ $labels.kind }}." - impact: "Events failing persistently - policy or cluster issue preventing recovery" - runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-high-retry-count.md" - - # ========================================================================= - # DLQ Retry Effectiveness - # ========================================================================= - - name: dlq-retry - interval: 60s - rules: - # Retry Not Succeeding - - alert: DLQRetryIneffective - expr: | - ( - sum(rate(activity_processor_dlq_retry_attempts_total{result="failed"}[15m])) - / - sum(rate(activity_processor_dlq_retry_attempts_total[15m])) - ) > 0.8 - for: 30m - labels: - severity: warning - component: activity-processor - team: platform-sre - annotations: - summary: "DLQ retry success rate is low" - description: "{{ $value | humanizePercentage }} of retry attempts are failing. Events not recovering after retry." - impact: "Automatic retry not effective - manual intervention may be required" - runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-retry-failing.md" - - # ========================================================================= - # Per-Policy DLQ Alerts - Policy Owner Responsibility - # ========================================================================= - - name: dlq-policy-errors - interval: 60s - rules: - # Policy Sending Events to DLQ - - alert: ActivityPolicyDLQErrors - expr: | - sum by (policy_name, api_group, kind, error_type) ( - rate(activity_processor_dlq_events_published_total{policy_name!=""}[10m]) - ) > 0.1 - for: 15m - labels: - severity: warning - component: activity-processor - annotations: - summary: "ActivityPolicy {{ $labels.policy_name }} sending events to DLQ" - description: "Policy {{ $labels.policy_name }} for {{ $labels.api_group }}/{{ $labels.kind }} has {{ $value }} events/sec failing with {{ $labels.error_type }}." - impact: "Activities not being generated for this resource type - users see incomplete timeline" - runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/policy-dlq-errors.md" +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: dlq-alerts + namespace: activity-system + labels: + prometheus: activity + app.kubernetes.io/part-of: activity + monitoring: "true" +spec: + groups: + # ========================================================================= + # DLQ Health Alerts - Platform SRE Ownership + # ========================================================================= + - name: dlq-health + interval: 30s + rules: + # DLQ Growing - Queue Not Draining + - alert: DLQQueueGrowing + expr: | + rate(activity_processor_dlq_events_published_total[5m]) > 0.1 + AND + increase(activity_processor_dlq_events_published_total[15m]) > 100 + for: 15m + labels: + severity: warning + component: activity-processor + team: platform-sre + annotations: + summary: "DLQ is growing - messages not being processed" + description: "DLQ is receiving {{ $value }} events/sec for 15+ minutes. Events are failing processing faster than retry can handle." + impact: "Failed events accumulating - may indicate systematic policy issue or processor problem" + runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-growth.md" + + # DLQ Publish Errors + - alert: DLQPublishErrors + expr: rate(activity_processor_dlq_publish_errors_total[5m]) > 0.1 + for: 10m + labels: + severity: warning + component: activity-processor + team: platform-sre + annotations: + summary: "Failed to publish events to DLQ" + description: "{{ $value }} DLQ publish errors/sec. Events may be lost entirely." + impact: "Failed events not being captured - complete data loss for affected events" + runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-publish-errors.md" + + # High Retry Count Events + - alert: DLQHighRetryCount + expr: | + increase(activity_processor_dlq_retry_events_high_retry_total[1h]) > 10 + for: 1h + labels: + severity: warning + component: activity-processor + team: platform-sre + annotations: + summary: "DLQ events with excessive retry attempts" + description: "{{ $value }} events have exceeded the high retry threshold for {{ $labels.api_group }}/{{ $labels.kind }}." + impact: "Events failing persistently - policy or cluster issue preventing recovery" + runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-high-retry-count.md" + + # ========================================================================= + # DLQ Retry Effectiveness + # ========================================================================= + - name: dlq-retry + interval: 60s + rules: + # Retry Not Succeeding + - alert: DLQRetryIneffective + expr: | + ( + sum(rate(activity_processor_dlq_retry_attempts_total{result="failed"}[15m])) + / + sum(rate(activity_processor_dlq_retry_attempts_total[15m])) + ) > 0.8 + for: 30m + labels: + severity: warning + component: activity-processor + team: platform-sre + annotations: + summary: "DLQ retry success rate is low" + description: "{{ $value | humanizePercentage }} of retry attempts are failing. Events not recovering after retry." + impact: "Automatic retry not effective - manual intervention may be required" + runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/dlq-retry-failing.md" + + # ========================================================================= + # Per-Policy DLQ Alerts - Policy Owner Responsibility + # ========================================================================= + - name: dlq-policy-errors + interval: 60s + rules: + # Policy Sending Events to DLQ + - alert: ActivityPolicyDLQErrors + expr: | + sum by (policy_name, api_group, kind, error_type) ( + rate(activity_processor_dlq_events_published_total{policy_name!=""}[10m]) + ) > 0.1 + for: 15m + labels: + severity: warning + component: activity-processor + annotations: + summary: "ActivityPolicy {{ $labels.policy_name }} sending events to DLQ" + description: "Policy {{ $labels.policy_name }} for {{ $labels.api_group }}/{{ $labels.kind }} has {{ $value }} events/sec failing with {{ $labels.error_type }}." + impact: "Activities not being generated for this resource type - users see incomplete timeline" + runbook_url: "https://github.com/datum-cloud/activity/blob/main/docs/runbooks/dlq/policy-dlq-errors.md" diff --git a/config/components/observability/alerts/generated/activity-recordings.yaml b/config/components/observability/alerts/generated/activity-recordings.yaml index d82b7e89..874d1a38 100644 --- a/config/components/observability/alerts/generated/activity-recordings.yaml +++ b/config/components/observability/alerts/generated/activity-recordings.yaml @@ -1,116 +1,116 @@ -"apiVersion": "monitoring.coreos.com/v1" -"kind": "PrometheusRule" -"metadata": - "labels": - "app.kubernetes.io/part-of": "activity" - "monitoring": "true" - "prometheus": "activity" - "name": "activity-recordings" - "namespace": "activity-system" -"spec": - "groups": - - "interval": "30s" - "name": "activity-recordings" - "rules": - - "expr": | - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - by (verb, resource, code) - "record": "activity:request_rate:5m" - - "expr": | - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - "record": "activity:request_rate_total:5m" - - "expr": | - sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) - / - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - "record": "activity:error_rate:5m" - - "expr": | - histogram_quantile(0.50, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - "record": "activity:apiserver_request_duration:p50" - - "expr": | - histogram_quantile(0.95, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - "record": "activity:apiserver_request_duration:p95" - - "expr": | - histogram_quantile(0.99, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - "record": "activity:apiserver_request_duration:p99" - - "expr": | - histogram_quantile(0.50, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - "record": "activity:query_duration:p50" - - "expr": | - histogram_quantile(0.95, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - "record": "activity:query_duration:p95" - - "expr": | - histogram_quantile(0.99, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - "record": "activity:query_duration:p99" - - "expr": | - sum(rate(activity_clickhouse_query_total[5m])) - by (status) - "record": "activity:query_rate:5m" - - "expr": | - sum(rate(activity_clickhouse_query_errors_total[5m])) - by (error_type) - "record": "activity:clickhouse_error_rate:5m" - - "expr": | - sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) - "record": "activity:vector_throughput:5m" - - "expr": | - sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) - "record": "activity:vector_writes:5m" - - "expr": | - sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) - - - sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) - "record": "activity:pipeline_lag:5m" - - "expr": | - nats_consumer_num_pending{stream_name="AUDIT_EVENTS",consumer_name="clickhouse-ingest"} - "record": "activity:nats_consumer_lag" - - "expr": | - rate(nats_stream_total_messages{stream_name="AUDIT_EVENTS"}[5m]) - "record": "activity:nats_message_rate:5m" - - "expr": | - sum(rate(container_cpu_usage_seconds_total{namespace="activity-system"}[5m])) - by (pod) - / - sum(container_spec_cpu_quota{namespace="activity-system"} / container_spec_cpu_period{namespace="activity-system"}) - by (pod) - "record": "activity:cpu_utilization" - - "expr": | - sum(container_memory_working_set_bytes{namespace="activity-system"}) - by (pod) - / - sum(container_spec_memory_limit_bytes{namespace="activity-system"}) - by (pod) - "record": "activity:memory_utilization" - - "expr": | - sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) - "record": "activity:vector_writes_events:5m" - - "expr": | - avg(rate(chi_clickhouse_table_parts_rows{chi="activity-clickhouse", database="audit", table="k8s_events", active="1"}[5m])) - "record": "activity:clickhouse_events_insert_rate:5m" - - "expr": | - sum(rate(chi_clickhouse_event_InsertQueryTimeMicroseconds{chi="activity-clickhouse"}[5m])) - / - clamp_min(sum(rate(chi_clickhouse_event_InsertQuery{chi="activity-clickhouse"}[5m])), 0.001) - / 1000000 - "record": "activity:clickhouse_events_insert_latency" - - "expr": | - sum(rate(event_exporter_events_published_total[5m])) - "record": "activity:event_exporter_throughput:5m" +"apiVersion": "monitoring.coreos.com/v1" +"kind": "PrometheusRule" +"metadata": + "labels": + "app.kubernetes.io/part-of": "activity" + "monitoring": "true" + "prometheus": "activity" + "name": "activity-recordings" + "namespace": "activity-system" +"spec": + "groups": + - "interval": "30s" + "name": "activity-recordings" + "rules": + - "expr": | + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + by (verb, resource, code) + "record": "activity:request_rate:5m" + - "expr": | + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + "record": "activity:request_rate_total:5m" + - "expr": | + sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) + / + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + "record": "activity:error_rate:5m" + - "expr": | + histogram_quantile(0.50, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + "record": "activity:apiserver_request_duration:p50" + - "expr": | + histogram_quantile(0.95, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + "record": "activity:apiserver_request_duration:p95" + - "expr": | + histogram_quantile(0.99, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + "record": "activity:apiserver_request_duration:p99" + - "expr": | + histogram_quantile(0.50, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + "record": "activity:query_duration:p50" + - "expr": | + histogram_quantile(0.95, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + "record": "activity:query_duration:p95" + - "expr": | + histogram_quantile(0.99, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + "record": "activity:query_duration:p99" + - "expr": | + sum(rate(activity_clickhouse_query_total[5m])) + by (status) + "record": "activity:query_rate:5m" + - "expr": | + sum(rate(activity_clickhouse_query_errors_total[5m])) + by (error_type) + "record": "activity:clickhouse_error_rate:5m" + - "expr": | + sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) + "record": "activity:vector_throughput:5m" + - "expr": | + sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) + "record": "activity:vector_writes:5m" + - "expr": | + sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) + - + sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) + "record": "activity:pipeline_lag:5m" + - "expr": | + nats_consumer_num_pending{stream_name="AUDIT_EVENTS",consumer_name="clickhouse-ingest"} + "record": "activity:nats_consumer_lag" + - "expr": | + rate(nats_stream_total_messages{stream_name="AUDIT_EVENTS"}[5m]) + "record": "activity:nats_message_rate:5m" + - "expr": | + sum(rate(container_cpu_usage_seconds_total{namespace="activity-system"}[5m])) + by (pod) + / + sum(container_spec_cpu_quota{namespace="activity-system"} / container_spec_cpu_period{namespace="activity-system"}) + by (pod) + "record": "activity:cpu_utilization" + - "expr": | + sum(container_memory_working_set_bytes{namespace="activity-system"}) + by (pod) + / + sum(container_spec_memory_limit_bytes{namespace="activity-system"}) + by (pod) + "record": "activity:memory_utilization" + - "expr": | + sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) + "record": "activity:vector_writes_events:5m" + - "expr": | + avg(rate(chi_clickhouse_table_parts_rows{chi="activity-clickhouse", database="audit", table="k8s_events", active="1"}[5m])) + "record": "activity:clickhouse_events_insert_rate:5m" + - "expr": | + sum(rate(chi_clickhouse_event_InsertQueryTimeMicroseconds{chi="activity-clickhouse"}[5m])) + / + clamp_min(sum(rate(chi_clickhouse_event_InsertQuery{chi="activity-clickhouse"}[5m])), 0.001) + / 1000000 + "record": "activity:clickhouse_events_insert_latency" + - "expr": | + sum(rate(event_exporter_events_published_total[5m])) + "record": "activity:event_exporter_throughput:5m" diff --git a/config/components/observability/alerts/vector-alerts.yaml b/config/components/observability/alerts/vector-alerts.yaml index 28d71049..cd12fa3b 100644 --- a/config/components/observability/alerts/vector-alerts.yaml +++ b/config/components/observability/alerts/vector-alerts.yaml @@ -1,133 +1,133 @@ -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: vector-alerts - namespace: activity-system - labels: - prometheus: activity - app.kubernetes.io/part-of: activity - monitoring: "true" -spec: - groups: - # ========================================================================= - # Vector Aggregator Health - NATS to ClickHouse Pipeline - # ========================================================================= - - name: vector-aggregator - interval: 30s - rules: - # NATS Source Stalled - Detects lame duck mode disconnection issue - # All NATS sources stopped receiving - strong signal of connection failure - - alert: VectorAllNATSSourcesStalled - expr: | - sum(rate(vector_component_received_events_total{component_type="nats"}[5m])) == 0 - for: 5m - labels: - severity: critical - component: vector-aggregator - issue: nats-reconnect - annotations: - summary: "All Vector NATS sources stopped - likely lame duck reconnection failure" - description: "No NATS sources are receiving events. This typically indicates Vector's NATS connection did not recover after server entered lame duck mode." - runbook: "Restart vector-aggregator pods: kubectl rollout restart statefulset/vector-aggregator -n activity-system" - issue_link: "https://github.com/datum-cloud/activity/issues/82" - - # NATS Source Stalled with backlog confirmation (requires NATS metrics) - - alert: VectorNATSSourceStalledWithBacklog - expr: | - ( - sum(rate(vector_component_received_events_total{component_type="nats"}[5m])) == 0 - ) - AND ON() - ( - sum(nats_jetstream_consumer_num_pending{consumer=~"clickhouse-ingest.*"}) > 100 - ) - for: 5m - labels: - severity: critical - component: vector-aggregator - issue: nats-reconnect - annotations: - summary: "Vector NATS stalled with message backlog" - description: "Vector not receiving from NATS but messages are pending in JetStream. Confirms reconnection failure." - runbook: "Restart vector-aggregator pods: kubectl rollout restart statefulset/vector-aggregator -n activity-system" - - # Individual NATS source not receiving - more granular detection - - alert: VectorNATSAuditSourceStopped - expr: | - rate(vector_component_received_events_total{component_id="nats_audit_consumer"}[10m]) == 0 - for: 10m - labels: - severity: warning - component: vector-aggregator - source: nats_audit_consumer - annotations: - summary: "Vector audit event source stopped receiving" - description: "The nats_audit_consumer source has not received events for 10+ minutes." - impact: "Audit events not flowing to ClickHouse" - - - alert: VectorNATSActivitiesSourceStopped - expr: | - rate(vector_component_received_events_total{component_id="nats_activities_consumer"}[10m]) == 0 - for: 10m - labels: - severity: warning - component: vector-aggregator - source: nats_activities_consumer - annotations: - summary: "Vector activities source stopped receiving" - description: "The nats_activities_consumer source has not received events for 10+ minutes." - impact: "Activities not flowing to ClickHouse" - - - alert: VectorNATSEventsSourceStopped - expr: | - rate(vector_component_received_events_total{component_id="nats_events_consumer"}[10m]) == 0 - for: 10m - labels: - severity: warning - component: vector-aggregator - source: nats_events_consumer - annotations: - summary: "Vector events source stopped receiving" - description: "The nats_events_consumer source has not received events for 10+ minutes." - impact: "Kubernetes events not flowing to ClickHouse" - - # Vector component errors - early warning - - alert: VectorComponentErrors - expr: | - rate(vector_component_errors_total{component_type="nats"}[5m]) > 0.1 - for: 5m - labels: - severity: warning - component: vector-aggregator - annotations: - summary: "Vector NATS component experiencing errors" - description: "{{ $labels.component_id }} has {{ $value | humanize }} errors/sec" - impact: "Potential data loss or pipeline degradation" - - # ClickHouse sink backpressure - buffer filling up - - alert: VectorClickHouseSinkBackpressure - expr: | - vector_buffer_events{component_type="clickhouse"} > 50000 - for: 10m - labels: - severity: warning - component: vector-aggregator - annotations: - summary: "Vector ClickHouse sink buffer filling up" - description: "{{ $value }} events buffered for {{ $labels.component_id }}. ClickHouse may be slow or unavailable." - impact: "Risk of data loss if buffer fills completely" - - # Vector pod not ready - - alert: VectorAggregatorNotReady - expr: | - kube_statefulset_status_replicas_ready{statefulset="vector-aggregator", namespace="activity-system"} - < - kube_statefulset_status_replicas{statefulset="vector-aggregator", namespace="activity-system"} - for: 5m - labels: - severity: warning - component: vector-aggregator - annotations: - summary: "Vector aggregator pods not ready" - description: "{{ $value }} vector-aggregator pods are not ready" - impact: "Reduced pipeline capacity" +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: vector-alerts + namespace: activity-system + labels: + prometheus: activity + app.kubernetes.io/part-of: activity + monitoring: "true" +spec: + groups: + # ========================================================================= + # Vector Aggregator Health - NATS to ClickHouse Pipeline + # ========================================================================= + - name: vector-aggregator + interval: 30s + rules: + # NATS Source Stalled - Detects lame duck mode disconnection issue + # All NATS sources stopped receiving - strong signal of connection failure + - alert: VectorAllNATSSourcesStalled + expr: | + sum(rate(vector_component_received_events_total{component_type="nats"}[5m])) == 0 + for: 5m + labels: + severity: critical + component: vector-aggregator + issue: nats-reconnect + annotations: + summary: "All Vector NATS sources stopped - likely lame duck reconnection failure" + description: "No NATS sources are receiving events. This typically indicates Vector's NATS connection did not recover after server entered lame duck mode." + runbook: "Restart vector-aggregator pods: kubectl rollout restart statefulset/vector-aggregator -n activity-system" + issue_link: "https://github.com/datum-cloud/activity/issues/82" + + # NATS Source Stalled with backlog confirmation (requires NATS metrics) + - alert: VectorNATSSourceStalledWithBacklog + expr: | + ( + sum(rate(vector_component_received_events_total{component_type="nats"}[5m])) == 0 + ) + AND ON() + ( + sum(nats_jetstream_consumer_num_pending{consumer=~"clickhouse-ingest.*"}) > 100 + ) + for: 5m + labels: + severity: critical + component: vector-aggregator + issue: nats-reconnect + annotations: + summary: "Vector NATS stalled with message backlog" + description: "Vector not receiving from NATS but messages are pending in JetStream. Confirms reconnection failure." + runbook: "Restart vector-aggregator pods: kubectl rollout restart statefulset/vector-aggregator -n activity-system" + + # Individual NATS source not receiving - more granular detection + - alert: VectorNATSAuditSourceStopped + expr: | + rate(vector_component_received_events_total{component_id="nats_audit_consumer"}[10m]) == 0 + for: 10m + labels: + severity: warning + component: vector-aggregator + source: nats_audit_consumer + annotations: + summary: "Vector audit event source stopped receiving" + description: "The nats_audit_consumer source has not received events for 10+ minutes." + impact: "Audit events not flowing to ClickHouse" + + - alert: VectorNATSActivitiesSourceStopped + expr: | + rate(vector_component_received_events_total{component_id="nats_activities_consumer"}[10m]) == 0 + for: 10m + labels: + severity: warning + component: vector-aggregator + source: nats_activities_consumer + annotations: + summary: "Vector activities source stopped receiving" + description: "The nats_activities_consumer source has not received events for 10+ minutes." + impact: "Activities not flowing to ClickHouse" + + - alert: VectorNATSEventsSourceStopped + expr: | + rate(vector_component_received_events_total{component_id="nats_events_consumer"}[10m]) == 0 + for: 10m + labels: + severity: warning + component: vector-aggregator + source: nats_events_consumer + annotations: + summary: "Vector events source stopped receiving" + description: "The nats_events_consumer source has not received events for 10+ minutes." + impact: "Kubernetes events not flowing to ClickHouse" + + # Vector component errors - early warning + - alert: VectorComponentErrors + expr: | + rate(vector_component_errors_total{component_type="nats"}[5m]) > 0.1 + for: 5m + labels: + severity: warning + component: vector-aggregator + annotations: + summary: "Vector NATS component experiencing errors" + description: "{{ $labels.component_id }} has {{ $value | humanize }} errors/sec" + impact: "Potential data loss or pipeline degradation" + + # ClickHouse sink backpressure - buffer filling up + - alert: VectorClickHouseSinkBackpressure + expr: | + vector_buffer_events{component_type="clickhouse"} > 50000 + for: 10m + labels: + severity: warning + component: vector-aggregator + annotations: + summary: "Vector ClickHouse sink buffer filling up" + description: "{{ $value }} events buffered for {{ $labels.component_id }}. ClickHouse may be slow or unavailable." + impact: "Risk of data loss if buffer fills completely" + + # Vector pod not ready + - alert: VectorAggregatorNotReady + expr: | + kube_statefulset_status_replicas_ready{statefulset="vector-aggregator", namespace="activity-system"} + < + kube_statefulset_status_replicas{statefulset="vector-aggregator", namespace="activity-system"} + for: 5m + labels: + severity: warning + component: vector-aggregator + annotations: + summary: "Vector aggregator pods not ready" + description: "{{ $value }} vector-aggregator pods are not ready" + impact: "Reduced pipeline capacity" diff --git a/config/components/observability/dashboards/generated/activity-processor.json b/config/components/observability/dashboards/generated/activity-processor.json index 24d5339d..91e41d41 100644 --- a/config/components/observability/dashboards/generated/activity-processor.json +++ b/config/components/observability/dashboards/generated/activity-processor.json @@ -1,1349 +1,1349 @@ -{ - "description": "Activity Processor metrics for event processing, policy evaluation, and NATS health", - "editable": true, - "graphTooltip": 1, - "panels": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of events received from NATS per second", - "fieldConfig": { - "defaults": { - "decimals": 1, - "unit": "ops" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 0 - }, - "id": 1, - "options": { - "colorMode": "value", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_events_received_total[5m]))", - "legendFormat": "Events/s" - } - ], - "title": "Event Processing Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of activities generated and published to NATS", - "fieldConfig": { - "defaults": { - "decimals": 1, - "unit": "ops" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 0 - }, - "id": 2, - "options": { - "colorMode": "value", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_activities_generated_total[5m]))", - "legendFormat": "Activities/s" - } - ], - "title": "Activity Generation Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Percentage of events that resulted in errors during processing", - "fieldConfig": { - "defaults": { - "decimals": 2, - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "yellow", - "value": 1 - }, - { - "color": "red", - "value": 5 - } - ] - }, - "unit": "percent" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 12, - "y": 0 - }, - "id": 3, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "(sum(rate(activity_processor_events_errored_total[5m])) or vector(0)) / (sum(rate(activity_processor_events_received_total[5m])) or vector(1)) * 100", - "legendFormat": "Error %" - } - ], - "title": "Error Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Number of ActivityPolicies currently loaded", - "fieldConfig": { - "defaults": { - "unit": "short" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 18, - "y": 0 - }, - "id": 4, - "options": { - "colorMode": "value", - "graphMode": "none", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "activity_processor_active_policies", - "instant": true, - "legendFormat": "Policies" - } - ], - "title": "Active Policies", - "type": "stat" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 0, - "x": 24, - "y": 5 - }, - "id": 5, - "panels": [ ], - "title": "Event Processing", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Event processing rate by Kubernetes API group", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "lineWidth": 2, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 6 - }, - "id": 6, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_events_received_total[5m])) by (api_group)", - "legendFormat": "{{api_group}}" - } - ], - "title": "Events by API Group", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Events evaluated against policies vs activities generated (conversion rate)", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "lineWidth": 2, - "showPoints": "never" - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 6 - }, - "id": 7, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_events_evaluated_total[5m]))", - "legendFormat": "Evaluated" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_activities_generated_total[5m]))", - "legendFormat": "Generated" - } - ], - "title": "Events Evaluated vs Generated", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Events skipped during processing, grouped by skip reason", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 14 - }, - "id": 8, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_events_skipped_total[5m])) by (reason)", - "legendFormat": "{{reason}}" - } - ], - "title": "Skipped Events by Reason", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "99th percentile processing duration per policy", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "showPoints": "never" - }, - "unit": "s" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 14 - }, - "id": 9, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.99, sum(rate(activity_processor_event_processing_duration_seconds_bucket[5m])) by (policy, le))", - "legendFormat": "{{policy}}" - } - ], - "title": "Processing Duration p99 by Policy", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 0, - "x": 24, - "y": 22 - }, - "id": 10, - "panels": [ ], - "title": "NATS Health", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Current NATS connection status (shows Disconnected if any instance is disconnected)", - "fieldConfig": { - "defaults": { - "mappings": [ - { - "options": { - "0": { - "color": "red", - "text": "Disconnected" - }, - "1": { - "color": "green", - "text": "Connected" - } - }, - "type": "value" - } - ] - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 23 - }, - "id": 11, - "options": { - "colorMode": "background", - "graphMode": "none", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "min(activity_processor_nats_connection_status{job=\"activity-processor\"})", - "instant": true, - "legendFormat": "Connected" - } - ], - "title": "NATS Connection Status", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Total number of NATS disconnection events across all instances", - "fieldConfig": { - "defaults": { - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "yellow", - "value": 1 - }, - { - "color": "red", - "value": 5 - } - ] - }, - "unit": "short" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 23 - }, - "id": 12, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(activity_processor_nats_disconnects_total)", - "legendFormat": "Disconnects" - } - ], - "title": "NATS Disconnects", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "99th percentile latency for NATS message publishing", - "fieldConfig": { - "defaults": { - "decimals": 3, - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "yellow", - "value": 0.10000000000000001 - }, - { - "color": "red", - "value": 0.5 - } - ] - }, - "unit": "s" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 12, - "y": 23 - }, - "id": 13, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p99" - } - ], - "title": "NATS Publish Latency p99", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of messages published to NATS per second", - "fieldConfig": { - "defaults": { - "decimals": 1, - "unit": "ops" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 18, - "y": 23 - }, - "id": 14, - "options": { - "colorMode": "value", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_nats_messages_published_total[5m]))", - "legendFormat": "Messages/s" - } - ], - "title": "Messages Published Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "NATS connection events over time (aggregated across all instances)", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 31 - }, - "id": 15, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_nats_disconnects_total[5m]))", - "legendFormat": "Disconnects" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_nats_reconnects_total[5m]))", - "legendFormat": "Reconnects" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_nats_errors_total[5m]))", - "legendFormat": "Errors" - } - ], - "title": "NATS Connection Events", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "NATS publish latency distribution", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "showPoints": "never" - }, - "unit": "s" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 31 - }, - "id": 16, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.95, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.50, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p50" - } - ], - "title": "NATS Publish Latency Percentiles", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 0, - "x": 24, - "y": 39 - }, - "id": 17, - "panels": [ ], - "title": "Worker Health", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Total number of active worker goroutines across all processor instances", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "lineWidth": 2, - "showPoints": "never" - }, - "unit": "short" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 40 - }, - "id": 18, - "options": { - "legend": { - "displayMode": "list", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(activity_processor_active_workers)", - "legendFormat": "Total Workers" - } - ], - "title": "Active Workers", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Processing errors broken down by error type", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 40 - }, - "id": 19, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_events_errored_total[5m])) by (error_type) or vector(0)", - "legendFormat": "{{error_type}}" - } - ], - "title": "Error Types Breakdown", - "type": "timeseries" - }, - { - "collapsed": false, - "gridPos": { - "h": 1, - "w": 0, - "x": 24, - "y": 48 - }, - "id": 20, - "panels": [ ], - "title": "Dead Letter Queue", - "type": "row" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of events published to the dead letter queue per second", - "fieldConfig": { - "defaults": { - "decimals": 1, - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "yellow", - "value": 0.10000000000000001 - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 0, - "y": 49 - }, - "id": 21, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_dlq_events_published_total[5m])) or vector(0)", - "legendFormat": "Events/s" - } - ], - "title": "DLQ Publish Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of errors encountered when publishing to the dead letter queue", - "fieldConfig": { - "defaults": { - "decimals": 1, - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 0.01 - } - ] - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 6, - "y": 49 - }, - "id": 22, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_dlq_publish_errors_total[5m])) or vector(0)", - "legendFormat": "Errors/s" - } - ], - "title": "DLQ Publish Errors", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Fraction of DLQ retry attempts that succeeded", - "fieldConfig": { - "defaults": { - "decimals": 1, - "thresholds": { - "steps": [ - { - "color": "red", - "value": null - }, - { - "color": "yellow", - "value": 0.80000000000000004 - }, - { - "color": "green", - "value": 0.94999999999999996 - } - ] - }, - "unit": "percentunit" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 12, - "y": 49 - }, - "id": 23, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "(sum(rate(activity_processor_dlq_retry_attempts_total{result=\"succeeded\"}[5m])) or vector(0)) / clamp_min(sum(rate(activity_processor_dlq_retry_attempts_total[5m])), 1)", - "legendFormat": "Success Rate" - } - ], - "title": "Retry Success Rate", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "DLQ events exceeding the high retry threshold in the last hour", - "fieldConfig": { - "defaults": { - "thresholds": { - "steps": [ - { - "color": "green", - "value": null - }, - { - "color": "red", - "value": 1 - } - ] - }, - "unit": "short" - } - }, - "gridPos": { - "h": 5, - "w": 6, - "x": 18, - "y": 49 - }, - "id": 24, - "options": { - "colorMode": "background", - "graphMode": "area", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ] - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(increase(activity_processor_dlq_retry_events_high_retry_total[1h])) or vector(0)", - "legendFormat": "Events" - } - ], - "title": "High Retry Events", - "type": "stat" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of DLQ events published, broken down by error type", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 57 - }, - "id": 25, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_dlq_events_published_total[5m])) by (error_type)", - "legendFormat": "{{error_type}}" - } - ], - "title": "DLQ Events by Error Type", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of DLQ events published, broken down by policy name", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 57 - }, - "id": 26, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "label_replace(sum(rate(activity_processor_dlq_events_published_total[5m])) by (policy_name), \"policy_name\", \"(no policy)\", \"policy_name\", \"^$\")", - "legendFormat": "{{policy_name}}" - } - ], - "title": "DLQ Events by Policy", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "Rate of DLQ retry attempts, broken down by trigger source and result", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 30, - "showPoints": "never", - "stacking": { - "mode": "normal" - } - }, - "unit": "ops" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 0, - "y": 65 - }, - "id": 27, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "sum(rate(activity_processor_dlq_retry_attempts_total[5m])) by (trigger, result)", - "legendFormat": "{{trigger}} - {{result}}" - } - ], - "title": "Retry Attempts", - "type": "timeseries" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "description": "DLQ publish latency distribution (p99, p95, p50)", - "fieldConfig": { - "defaults": { - "custom": { - "fillOpacity": 10, - "lineWidth": 2, - "showPoints": "never" - }, - "unit": "s" - } - }, - "gridPos": { - "h": 8, - "w": 12, - "x": 12, - "y": 65 - }, - "id": 28, - "options": { - "legend": { - "calcs": [ - "lastNotNull", - "mean", - "max" - ], - "displayMode": "table", - "placement": "bottom", - "showLegend": true - } - }, - "pluginVersion": "v11.4.0", - "targets": [ - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.99, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p99" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.95, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p95" - }, - { - "datasource": { - "type": "prometheus", - "uid": "$datasource" - }, - "expr": "histogram_quantile(0.50, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", - "legendFormat": "p50" - } - ], - "title": "DLQ Publish Latency", - "type": "timeseries" - } - ], - "refresh": "30s", - "schemaVersion": 39, - "tags": [ - "activity", - "processor", - "pipeline", - "nats" - ], - "templating": { - "list": [ - { - "label": "Prometheus Datasource", - "name": "datasource", - "query": "prometheus", - "regex": "", - "type": "datasource" - } - ] - }, - "time": { - "from": "now-24h", - "to": "now" - }, - "timezone": "utc", - "title": "Activity Processor - Event Pipeline", - "uid": "activity-processor" -} +{ + "description": "Activity Processor metrics for event processing, policy evaluation, and NATS health", + "editable": true, + "graphTooltip": 1, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of events received from NATS per second", + "fieldConfig": { + "defaults": { + "decimals": 1, + "unit": "ops" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_events_received_total[5m]))", + "legendFormat": "Events/s" + } + ], + "title": "Event Processing Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of activities generated and published to NATS", + "fieldConfig": { + "defaults": { + "decimals": 1, + "unit": "ops" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_activities_generated_total[5m]))", + "legendFormat": "Activities/s" + } + ], + "title": "Activity Generation Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Percentage of events that resulted in errors during processing", + "fieldConfig": { + "defaults": { + "decimals": 2, + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percent" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "(sum(rate(activity_processor_events_errored_total[5m])) or vector(0)) / (sum(rate(activity_processor_events_received_total[5m])) or vector(1)) * 100", + "legendFormat": "Error %" + } + ], + "title": "Error Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Number of ActivityPolicies currently loaded", + "fieldConfig": { + "defaults": { + "unit": "short" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "none", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "activity_processor_active_policies", + "instant": true, + "legendFormat": "Policies" + } + ], + "title": "Active Policies", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 0, + "x": 24, + "y": 5 + }, + "id": 5, + "panels": [ ], + "title": "Event Processing", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Event processing rate by Kubernetes API group", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 6 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_events_received_total[5m])) by (api_group)", + "legendFormat": "{{api_group}}" + } + ], + "title": "Events by API Group", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Events evaluated against policies vs activities generated (conversion rate)", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never" + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 6 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_events_evaluated_total[5m]))", + "legendFormat": "Evaluated" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_activities_generated_total[5m]))", + "legendFormat": "Generated" + } + ], + "title": "Events Evaluated vs Generated", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Events skipped during processing, grouped by skip reason", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 14 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_events_skipped_total[5m])) by (reason)", + "legendFormat": "{{reason}}" + } + ], + "title": "Skipped Events by Reason", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "99th percentile processing duration per policy", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "showPoints": "never" + }, + "unit": "s" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 14 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.99, sum(rate(activity_processor_event_processing_duration_seconds_bucket[5m])) by (policy, le))", + "legendFormat": "{{policy}}" + } + ], + "title": "Processing Duration p99 by Policy", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 0, + "x": 24, + "y": 22 + }, + "id": 10, + "panels": [ ], + "title": "NATS Health", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Current NATS connection status (shows Disconnected if any instance is disconnected)", + "fieldConfig": { + "defaults": { + "mappings": [ + { + "options": { + "0": { + "color": "red", + "text": "Disconnected" + }, + "1": { + "color": "green", + "text": "Connected" + } + }, + "type": "value" + } + ] + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 23 + }, + "id": 11, + "options": { + "colorMode": "background", + "graphMode": "none", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "min(activity_processor_nats_connection_status{job=\"activity-processor\"})", + "instant": true, + "legendFormat": "Connected" + } + ], + "title": "NATS Connection Status", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Total number of NATS disconnection events across all instances", + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 23 + }, + "id": 12, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(activity_processor_nats_disconnects_total)", + "legendFormat": "Disconnects" + } + ], + "title": "NATS Disconnects", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "99th percentile latency for NATS message publishing", + "fieldConfig": { + "defaults": { + "decimals": 3, + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.10000000000000001 + }, + { + "color": "red", + "value": 0.5 + } + ] + }, + "unit": "s" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 23 + }, + "id": 13, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p99" + } + ], + "title": "NATS Publish Latency p99", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of messages published to NATS per second", + "fieldConfig": { + "defaults": { + "decimals": 1, + "unit": "ops" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 23 + }, + "id": 14, + "options": { + "colorMode": "value", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_nats_messages_published_total[5m]))", + "legendFormat": "Messages/s" + } + ], + "title": "Messages Published Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "NATS connection events over time (aggregated across all instances)", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 31 + }, + "id": 15, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_nats_disconnects_total[5m]))", + "legendFormat": "Disconnects" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_nats_reconnects_total[5m]))", + "legendFormat": "Reconnects" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_nats_errors_total[5m]))", + "legendFormat": "Errors" + } + ], + "title": "NATS Connection Events", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "NATS publish latency distribution", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "showPoints": "never" + }, + "unit": "s" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 31 + }, + "id": 16, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p99" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.95, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.50, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p50" + } + ], + "title": "NATS Publish Latency Percentiles", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 0, + "x": 24, + "y": 39 + }, + "id": 17, + "panels": [ ], + "title": "Worker Health", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Total number of active worker goroutines across all processor instances", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never" + }, + "unit": "short" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 18, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(activity_processor_active_workers)", + "legendFormat": "Total Workers" + } + ], + "title": "Active Workers", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Processing errors broken down by error type", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 40 + }, + "id": 19, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_events_errored_total[5m])) by (error_type) or vector(0)", + "legendFormat": "{{error_type}}" + } + ], + "title": "Error Types Breakdown", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 0, + "x": 24, + "y": 48 + }, + "id": 20, + "panels": [ ], + "title": "Dead Letter Queue", + "type": "row" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of events published to the dead letter queue per second", + "fieldConfig": { + "defaults": { + "decimals": 1, + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.10000000000000001 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 49 + }, + "id": 21, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_dlq_events_published_total[5m])) or vector(0)", + "legendFormat": "Events/s" + } + ], + "title": "DLQ Publish Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of errors encountered when publishing to the dead letter queue", + "fieldConfig": { + "defaults": { + "decimals": 1, + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 0.01 + } + ] + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 49 + }, + "id": 22, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_dlq_publish_errors_total[5m])) or vector(0)", + "legendFormat": "Errors/s" + } + ], + "title": "DLQ Publish Errors", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Fraction of DLQ retry attempts that succeeded", + "fieldConfig": { + "defaults": { + "decimals": 1, + "thresholds": { + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0.80000000000000004 + }, + { + "color": "green", + "value": 0.94999999999999996 + } + ] + }, + "unit": "percentunit" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 49 + }, + "id": 23, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "(sum(rate(activity_processor_dlq_retry_attempts_total{result=\"succeeded\"}[5m])) or vector(0)) / clamp_min(sum(rate(activity_processor_dlq_retry_attempts_total[5m])), 1)", + "legendFormat": "Success Rate" + } + ], + "title": "Retry Success Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "DLQ events exceeding the high retry threshold in the last hour", + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 49 + }, + "id": 24, + "options": { + "colorMode": "background", + "graphMode": "area", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ] + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(increase(activity_processor_dlq_retry_events_high_retry_total[1h])) or vector(0)", + "legendFormat": "Events" + } + ], + "title": "High Retry Events", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of DLQ events published, broken down by error type", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 57 + }, + "id": 25, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_dlq_events_published_total[5m])) by (error_type)", + "legendFormat": "{{error_type}}" + } + ], + "title": "DLQ Events by Error Type", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of DLQ events published, broken down by policy name", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 57 + }, + "id": 26, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "label_replace(sum(rate(activity_processor_dlq_events_published_total[5m])) by (policy_name), \"policy_name\", \"(no policy)\", \"policy_name\", \"^$\")", + "legendFormat": "{{policy_name}}" + } + ], + "title": "DLQ Events by Policy", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "Rate of DLQ retry attempts, broken down by trigger source and result", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 30, + "showPoints": "never", + "stacking": { + "mode": "normal" + } + }, + "unit": "ops" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 65 + }, + "id": 27, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "sum(rate(activity_processor_dlq_retry_attempts_total[5m])) by (trigger, result)", + "legendFormat": "{{trigger}} - {{result}}" + } + ], + "title": "Retry Attempts", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "description": "DLQ publish latency distribution (p99, p95, p50)", + "fieldConfig": { + "defaults": { + "custom": { + "fillOpacity": 10, + "lineWidth": 2, + "showPoints": "never" + }, + "unit": "s" + } + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 65 + }, + "id": 28, + "options": { + "legend": { + "calcs": [ + "lastNotNull", + "mean", + "max" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "pluginVersion": "v11.4.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.99, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p99" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.95, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p95" + }, + { + "datasource": { + "type": "prometheus", + "uid": "$datasource" + }, + "expr": "histogram_quantile(0.50, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))", + "legendFormat": "p50" + } + ], + "title": "DLQ Publish Latency", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "activity", + "processor", + "pipeline", + "nats" + ], + "templating": { + "list": [ + { + "label": "Prometheus Datasource", + "name": "datasource", + "query": "prometheus", + "regex": "", + "type": "datasource" + } + ] + }, + "time": { + "from": "now-24h", + "to": "now" + }, + "timezone": "utc", + "title": "Activity Processor - Event Pipeline", + "uid": "activity-processor" +} diff --git a/config/components/observability/dashboards/kustomization.yaml b/config/components/observability/dashboards/kustomization.yaml index 8a59177c..82a29ee8 100644 --- a/config/components/observability/dashboards/kustomization.yaml +++ b/config/components/observability/dashboards/kustomization.yaml @@ -1,52 +1,52 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: activity-system - -configMapGenerator: - - name: audit-pipeline-dashboard - files: - - generated/audit-pipeline.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true - - - name: activity-apiserver-dashboard - files: - - generated/activity-apiserver.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true - - - name: activity-processor-dashboard - files: - - generated/activity-processor.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true - - - name: events-pipeline-dashboard - files: - - generated/events-pipeline.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true - - - name: activity-system-overview-dashboard - files: - - generated/activity-system-overview.json - options: - labels: - grafana_dashboard: "1" - disableNameSuffixHash: true - -resources: - - audit-pipeline-grafanadashboard.yaml - - activity-apiserver-grafanadashboard.yaml - - activity-processor-grafanadashboard.yaml - - events-pipeline-grafanadashboard.yaml - - activity-system-overview-grafanadashboard.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: activity-system + +configMapGenerator: + - name: audit-pipeline-dashboard + files: + - generated/audit-pipeline.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true + + - name: activity-apiserver-dashboard + files: + - generated/activity-apiserver.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true + + - name: activity-processor-dashboard + files: + - generated/activity-processor.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true + + - name: events-pipeline-dashboard + files: + - generated/events-pipeline.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true + + - name: activity-system-overview-dashboard + files: + - generated/activity-system-overview.json + options: + labels: + grafana_dashboard: "1" + disableNameSuffixHash: true + +resources: + - audit-pipeline-grafanadashboard.yaml + - activity-apiserver-grafanadashboard.yaml + - activity-processor-grafanadashboard.yaml + - events-pipeline-grafanadashboard.yaml + - activity-system-overview-grafanadashboard.yaml diff --git a/config/components/observability/kustomization.yaml b/config/components/observability/kustomization.yaml index 9bfafc45..eff36575 100644 --- a/config/components/observability/kustomization.yaml +++ b/config/components/observability/kustomization.yaml @@ -1,26 +1,26 @@ -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component - -resources: - # RBAC for metrics access - - rbac-metrics.yaml - - # ServiceMonitors for metrics collection - - servicemonitors/activity-apiserver-servicemonitor.yaml - - servicemonitors/activity-processor-servicemonitor.yaml - - servicemonitors/k8s-event-exporter-servicemonitor.yaml - - # Alert rules (complete manual file with all component alerts) - - alerts/activity-alerts.yaml - # DLQ-specific alerts - - alerts/dlq-alerts.yaml - # Vector aggregator alerts (NATS reconnection detection) - - alerts/vector-alerts.yaml - # Recording rules (generated from Jsonnet mixin) - - alerts/generated/activity-recordings.yaml - - # PrometheusRule resources for component-specific alerts - - prometheusrules/clickhouse-alerts.yaml - - # Grafana dashboards - - dashboards/ +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +resources: + # RBAC for metrics access + - rbac-metrics.yaml + + # ServiceMonitors for metrics collection + - servicemonitors/activity-apiserver-servicemonitor.yaml + - servicemonitors/activity-processor-servicemonitor.yaml + - servicemonitors/k8s-event-exporter-servicemonitor.yaml + + # Alert rules (complete manual file with all component alerts) + - alerts/activity-alerts.yaml + # DLQ-specific alerts + - alerts/dlq-alerts.yaml + # Vector aggregator alerts (NATS reconnection detection) + - alerts/vector-alerts.yaml + # Recording rules (generated from Jsonnet mixin) + - alerts/generated/activity-recordings.yaml + + # PrometheusRule resources for component-specific alerts + - prometheusrules/clickhouse-alerts.yaml + + # Grafana dashboards + - dashboards/ diff --git a/config/components/observability/prometheusrules/clickhouse-alerts.yaml b/config/components/observability/prometheusrules/clickhouse-alerts.yaml index 354366b1..43b1bb53 100644 --- a/config/components/observability/prometheusrules/clickhouse-alerts.yaml +++ b/config/components/observability/prometheusrules/clickhouse-alerts.yaml @@ -1,175 +1,175 @@ -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: clickhouse-alerts - labels: - team: platform - component: clickhouse -spec: - groups: - - name: clickhouse.health - interval: 30s - rules: - # Data integrity alerts - - alert: ClickHouseReplicatedPartChecksFailed - expr: | - chi_clickhouse_metric_ReplicatedPartChecksFailed > 0 - for: 5m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "ClickHouse part checks failing on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has {{ $value }} replicated part check failures. - This may indicate corrupted data parts or schema migration issues. - Check logs for details: kubectl logs -n {{ $labels.namespace }} {{ $labels.pod_name }} - runbook_url: "https://runbooks.datum.net/clickhouse/part-checks-failed" - - # Merge executor saturation - critical for write performance - - alert: ClickHouseMergeExecutorSaturation - expr: | - ( - chi_clickhouse_metric_BackgroundMergesAndMutationsPoolTask - / - chi_clickhouse_metric_BackgroundMergesAndMutationsPoolSize - ) > 0.9 - for: 10m - labels: - severity: critical - team: platform - component: clickhouse - annotations: - summary: "ClickHouse merge executor saturated on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has merge executor at {{ $value | humanizePercentage }} capacity for 10 minutes. - This will cause write performance degradation and increased part count. - Current tasks: {{ printf "chi_clickhouse_metric_BackgroundMergesAndMutationsPoolTask{hostname='%s'}" $labels.hostname | query | first | value }} - Pool size: {{ printf "chi_clickhouse_metric_BackgroundMergesAndMutationsPoolSize{hostname='%s'}" $labels.hostname | query | first | value }} - runbook_url: "https://runbooks.datum.net/clickhouse/merge-saturation" - - # Insert failure rate - - alert: ClickHouseFailedInserts - expr: | - rate(chi_clickhouse_event_FailedInsertQuery[5m]) > 0.1 - for: 5m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "High ClickHouse insert failure rate on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} failed inserts/sec. - This may indicate schema issues, resource exhaustion, or network problems. - Check processor logs for insert errors. - runbook_url: "https://runbooks.datum.net/clickhouse/failed-inserts" - - # General query failure rate - - alert: ClickHouseFailedQueries - expr: | - rate(chi_clickhouse_event_FailedQuery[5m]) > 0.5 - for: 5m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "High ClickHouse query failure rate on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} failed queries/sec. - This may indicate schema issues, memory pressure, or malformed queries. - Check apiserver and processor logs for query errors. - runbook_url: "https://runbooks.datum.net/clickhouse/failed-queries" - - # ZooKeeper connectivity issues (can indicate cluster problems) - - alert: ClickHouseZooKeeperExceptions - expr: | - rate(chi_clickhouse_event_ZooKeeperHardwareExceptions[5m]) > 0 - for: 10m - labels: - severity: info - team: platform - component: clickhouse - annotations: - summary: "ClickHouse ZooKeeper exceptions on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} ZooKeeper exceptions/sec. - This may indicate network issues or ZooKeeper cluster problems. - While transient exceptions are normal, sustained rate may affect replication. - runbook_url: "https://runbooks.datum.net/clickhouse/zookeeper-exceptions" - - # Replication lag - critical for data consistency - - alert: ClickHouseReplicationLagHigh - expr: | - chi_clickhouse_metric_ReplicasMaxAbsoluteDelay > 300 - for: 5m - labels: - severity: critical - team: platform - component: clickhouse - annotations: - summary: "High ClickHouse replication lag on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has replication lag of {{ $value | humanizeDuration }}. - Replicas are not caught up with the leader, which may affect read consistency. - This could indicate network issues, resource saturation, or schema conflicts. - Check replication queue: SELECT * FROM system.replication_queue FORMAT Vertical - runbook_url: "https://runbooks.datum.net/clickhouse/replication-lag" - - # Too many parts - can indicate merge issues - - alert: ClickHouseTooManyParts - expr: | - chi_clickhouse_metric_MaxPartCountForPartition > 300 - for: 10m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "ClickHouse has too many parts on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has {{ $value }} parts in a partition. - This indicates merges are not keeping up with inserts. - May cause "Too many parts" errors and require manual OPTIMIZE TABLE. - Check merge executor saturation and background pool settings. - runbook_url: "https://runbooks.datum.net/clickhouse/too-many-parts" - - - name: clickhouse.replication - interval: 30s - rules: - # Replication queue size - - alert: ClickHouseReplicationQueueLarge - expr: | - chi_clickhouse_metric_ReplicatedFetchQueue > 100 - for: 15m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "Large ClickHouse replication queue on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has {{ $value }} items in replication fetch queue. - This indicates the replica is falling behind and may take time to catch up. - Monitor ReplicasMaxAbsoluteDelay metric for actual lag. - runbook_url: "https://runbooks.datum.net/clickhouse/replication-queue" - - # Detached parts - indicates manual intervention needed - - alert: ClickHouseDetachedParts - expr: | - chi_clickhouse_metric_NumberOfDetachedParts > 0 - for: 30m - labels: - severity: warning - team: platform - component: clickhouse - annotations: - summary: "ClickHouse has detached parts on {{ $labels.hostname }}" - description: | - ClickHouse instance {{ $labels.hostname }} has {{ $value }} detached parts. - Detached parts are not included in queries and may indicate corruption or migration issues. - Investigate: SELECT * FROM system.detached_parts - These may need to be manually attached or dropped. - runbook_url: "https://runbooks.datum.net/clickhouse/detached-parts" +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: clickhouse-alerts + labels: + team: platform + component: clickhouse +spec: + groups: + - name: clickhouse.health + interval: 30s + rules: + # Data integrity alerts + - alert: ClickHouseReplicatedPartChecksFailed + expr: | + chi_clickhouse_metric_ReplicatedPartChecksFailed > 0 + for: 5m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "ClickHouse part checks failing on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has {{ $value }} replicated part check failures. + This may indicate corrupted data parts or schema migration issues. + Check logs for details: kubectl logs -n {{ $labels.namespace }} {{ $labels.pod_name }} + runbook_url: "https://runbooks.datum.net/clickhouse/part-checks-failed" + + # Merge executor saturation - critical for write performance + - alert: ClickHouseMergeExecutorSaturation + expr: | + ( + chi_clickhouse_metric_BackgroundMergesAndMutationsPoolTask + / + chi_clickhouse_metric_BackgroundMergesAndMutationsPoolSize + ) > 0.9 + for: 10m + labels: + severity: critical + team: platform + component: clickhouse + annotations: + summary: "ClickHouse merge executor saturated on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has merge executor at {{ $value | humanizePercentage }} capacity for 10 minutes. + This will cause write performance degradation and increased part count. + Current tasks: {{ printf "chi_clickhouse_metric_BackgroundMergesAndMutationsPoolTask{hostname='%s'}" $labels.hostname | query | first | value }} + Pool size: {{ printf "chi_clickhouse_metric_BackgroundMergesAndMutationsPoolSize{hostname='%s'}" $labels.hostname | query | first | value }} + runbook_url: "https://runbooks.datum.net/clickhouse/merge-saturation" + + # Insert failure rate + - alert: ClickHouseFailedInserts + expr: | + rate(chi_clickhouse_event_FailedInsertQuery[5m]) > 0.1 + for: 5m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "High ClickHouse insert failure rate on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} failed inserts/sec. + This may indicate schema issues, resource exhaustion, or network problems. + Check processor logs for insert errors. + runbook_url: "https://runbooks.datum.net/clickhouse/failed-inserts" + + # General query failure rate + - alert: ClickHouseFailedQueries + expr: | + rate(chi_clickhouse_event_FailedQuery[5m]) > 0.5 + for: 5m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "High ClickHouse query failure rate on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} failed queries/sec. + This may indicate schema issues, memory pressure, or malformed queries. + Check apiserver and processor logs for query errors. + runbook_url: "https://runbooks.datum.net/clickhouse/failed-queries" + + # ZooKeeper connectivity issues (can indicate cluster problems) + - alert: ClickHouseZooKeeperExceptions + expr: | + rate(chi_clickhouse_event_ZooKeeperHardwareExceptions[5m]) > 0 + for: 10m + labels: + severity: info + team: platform + component: clickhouse + annotations: + summary: "ClickHouse ZooKeeper exceptions on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} is experiencing {{ $value | humanize }} ZooKeeper exceptions/sec. + This may indicate network issues or ZooKeeper cluster problems. + While transient exceptions are normal, sustained rate may affect replication. + runbook_url: "https://runbooks.datum.net/clickhouse/zookeeper-exceptions" + + # Replication lag - critical for data consistency + - alert: ClickHouseReplicationLagHigh + expr: | + chi_clickhouse_metric_ReplicasMaxAbsoluteDelay > 300 + for: 5m + labels: + severity: critical + team: platform + component: clickhouse + annotations: + summary: "High ClickHouse replication lag on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has replication lag of {{ $value | humanizeDuration }}. + Replicas are not caught up with the leader, which may affect read consistency. + This could indicate network issues, resource saturation, or schema conflicts. + Check replication queue: SELECT * FROM system.replication_queue FORMAT Vertical + runbook_url: "https://runbooks.datum.net/clickhouse/replication-lag" + + # Too many parts - can indicate merge issues + - alert: ClickHouseTooManyParts + expr: | + chi_clickhouse_metric_MaxPartCountForPartition > 300 + for: 10m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "ClickHouse has too many parts on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has {{ $value }} parts in a partition. + This indicates merges are not keeping up with inserts. + May cause "Too many parts" errors and require manual OPTIMIZE TABLE. + Check merge executor saturation and background pool settings. + runbook_url: "https://runbooks.datum.net/clickhouse/too-many-parts" + + - name: clickhouse.replication + interval: 30s + rules: + # Replication queue size + - alert: ClickHouseReplicationQueueLarge + expr: | + chi_clickhouse_metric_ReplicatedFetchQueue > 100 + for: 15m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "Large ClickHouse replication queue on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has {{ $value }} items in replication fetch queue. + This indicates the replica is falling behind and may take time to catch up. + Monitor ReplicasMaxAbsoluteDelay metric for actual lag. + runbook_url: "https://runbooks.datum.net/clickhouse/replication-queue" + + # Detached parts - indicates manual intervention needed + - alert: ClickHouseDetachedParts + expr: | + chi_clickhouse_metric_NumberOfDetachedParts > 0 + for: 30m + labels: + severity: warning + team: platform + component: clickhouse + annotations: + summary: "ClickHouse has detached parts on {{ $labels.hostname }}" + description: | + ClickHouse instance {{ $labels.hostname }} has {{ $value }} detached parts. + Detached parts are not included in queries and may indicate corruption or migration issues. + Investigate: SELECT * FROM system.detached_parts + These may need to be manually attached or dropped. + runbook_url: "https://runbooks.datum.net/clickhouse/detached-parts" diff --git a/config/components/ui/deployment.yaml b/config/components/ui/deployment.yaml index 073fedd0..85c2fce5 100644 --- a/config/components/ui/deployment.yaml +++ b/config/components/ui/deployment.yaml @@ -1,100 +1,100 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-ui - namespace: activity-system - labels: - app: activity-ui -spec: - replicas: 1 - selector: - matchLabels: - app: activity-ui - template: - metadata: - labels: - app: activity-ui - spec: - serviceAccountName: activity-ui - securityContext: - runAsNonRoot: true - runAsUser: 1000 - runAsGroup: 1000 - fsGroup: 1000 - seccompProfile: - type: RuntimeDefault - containers: - - name: ui - image: ghcr.io/datum-cloud/activity-ui:latest - imagePullPolicy: IfNotPresent - securityContext: - allowPrivilegeEscalation: false - readOnlyRootFilesystem: false - runAsNonRoot: true - runAsUser: 1000 - capabilities: - drop: - - ALL - env: - # URL to the activity-apiserver for proxying API requests - - name: ACTIVITY_API_SERVER_URL - value: "https://activity-apiserver.activity-system.svc:443" - # Use the control plane CA for TLS verification (matches apiserver's serving cert) - - name: ACTIVITY_API_CA_FILE - value: "/etc/activity-apiserver-ca/ca.crt" - # Client certificate for mTLS authentication to activity-apiserver - - name: ACTIVITY_API_CERT_FILE - value: "/etc/activity-client-cert/tls.crt" - - name: ACTIVITY_API_KEY_FILE - value: "/etc/activity-client-cert/tls.key" - ports: - - containerPort: 3000 - name: http - protocol: TCP - resources: - requests: - cpu: 10m - memory: 64Mi - limits: - cpu: 200m - memory: 128Mi - livenessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - readinessProbe: - httpGet: - path: /health - port: http - initialDelaySeconds: 3 - periodSeconds: 5 - volumeMounts: - - name: tmp - mountPath: /tmp - - name: activity-apiserver-ca - mountPath: /etc/activity-apiserver-ca - readOnly: true - - name: activity-client-cert - mountPath: /etc/activity-client-cert - readOnly: true - volumes: - - name: tmp - emptyDir: {} - - name: activity-apiserver-ca - configMap: - name: datum-control-plane-trust-bundle - - name: activity-client-cert - csi: - driver: csi.cert-manager.io - readOnly: true - volumeAttributes: - csi.cert-manager.io/issuer-kind: ClusterIssuer - csi.cert-manager.io/issuer-name: datum-control-plane - csi.cert-manager.io/common-name: activity-ui - csi.cert-manager.io/organizations: "system:masters" - csi.cert-manager.io/key-usages: "client auth" - csi.cert-manager.io/duration: "8760h" - csi.cert-manager.io/renew-before: "720h" - csi.cert-manager.io/fs-group: "1000" +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-ui + namespace: activity-system + labels: + app: activity-ui +spec: + replicas: 1 + selector: + matchLabels: + app: activity-ui + template: + metadata: + labels: + app: activity-ui + spec: + serviceAccountName: activity-ui + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: ui + image: ghcr.io/datum-cloud/activity-ui:latest + imagePullPolicy: IfNotPresent + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + env: + # URL to the activity-apiserver for proxying API requests + - name: ACTIVITY_API_SERVER_URL + value: "https://activity-apiserver.activity-system.svc:443" + # Use the control plane CA for TLS verification (matches apiserver's serving cert) + - name: ACTIVITY_API_CA_FILE + value: "/etc/activity-apiserver-ca/ca.crt" + # Client certificate for mTLS authentication to activity-apiserver + - name: ACTIVITY_API_CERT_FILE + value: "/etc/activity-client-cert/tls.crt" + - name: ACTIVITY_API_KEY_FILE + value: "/etc/activity-client-cert/tls.key" + ports: + - containerPort: 3000 + name: http + protocol: TCP + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 200m + memory: 128Mi + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 3 + periodSeconds: 5 + volumeMounts: + - name: tmp + mountPath: /tmp + - name: activity-apiserver-ca + mountPath: /etc/activity-apiserver-ca + readOnly: true + - name: activity-client-cert + mountPath: /etc/activity-client-cert + readOnly: true + volumes: + - name: tmp + emptyDir: {} + - name: activity-apiserver-ca + configMap: + name: datum-control-plane-trust-bundle + - name: activity-client-cert + csi: + driver: csi.cert-manager.io + readOnly: true + volumeAttributes: + csi.cert-manager.io/issuer-kind: ClusterIssuer + csi.cert-manager.io/issuer-name: datum-control-plane + csi.cert-manager.io/common-name: activity-ui + csi.cert-manager.io/organizations: "system:masters" + csi.cert-manager.io/key-usages: "client auth" + csi.cert-manager.io/duration: "8760h" + csi.cert-manager.io/renew-before: "720h" + csi.cert-manager.io/fs-group: "1000" diff --git a/config/milo/iam/resources/kustomization.yaml b/config/milo/iam/resources/kustomization.yaml index baaf70d3..67c8f60c 100644 --- a/config/milo/iam/resources/kustomization.yaml +++ b/config/milo/iam/resources/kustomization.yaml @@ -1,13 +1,13 @@ -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component - -resources: - - activities.yaml - - activityqueries.yaml - - activityfacetqueries.yaml - - auditlogqueries.yaml - - auditlogfacetsqueries.yaml - - policypreviews.yaml - - events.yaml - - eventqueries.yaml - - eventfacetqueries.yaml +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +resources: + - activities.yaml + - activityqueries.yaml + - activityfacetqueries.yaml + - auditlogqueries.yaml + - auditlogfacetsqueries.yaml + - policypreviews.yaml + - events.yaml + - eventqueries.yaml + - eventfacetqueries.yaml diff --git a/config/milo/iam/roles/activity-viewer.yaml b/config/milo/iam/roles/activity-viewer.yaml index 7e05af6a..d15e18ae 100644 --- a/config/milo/iam/roles/activity-viewer.yaml +++ b/config/milo/iam/roles/activity-viewer.yaml @@ -1,17 +1,20 @@ -apiVersion: iam.miloapis.com/v1alpha1 -kind: Role -metadata: - name: activity.miloapis.com-activity-viewer - namespace: milo-system - labels: - app.kubernetes.io/name: activity-viewer - app.kubernetes.io/part-of: activity.miloapis.com -spec: - launchStage: Beta - includedPermissions: - # Activity feed - list and watch for real-time streaming - - activity.miloapis.com/activities.list - - activity.miloapis.com/activities.watch - # Activity queries - search historical activities - - activity.miloapis.com/activityqueries.create - - activity.miloapis.com/activityfacetqueries.create +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: activity.miloapis.com-activity-viewer + namespace: milo-system + annotations: + kubernetes.io/display-name: Activity Viewer + kubernetes.io/description: View activity feeds and query historical activities and facets + labels: + app.kubernetes.io/name: activity-viewer + app.kubernetes.io/part-of: activity.miloapis.com +spec: + launchStage: Beta + includedPermissions: + # Activity feed - list and watch for real-time streaming + - activity.miloapis.com/activities.list + - activity.miloapis.com/activities.watch + # Activity queries - search historical activities + - activity.miloapis.com/activityqueries.create + - activity.miloapis.com/activityfacetqueries.create diff --git a/config/milo/iam/roles/audit-log-querier.yaml b/config/milo/iam/roles/audit-log-querier.yaml index 8ff15867..5fb689eb 100644 --- a/config/milo/iam/roles/audit-log-querier.yaml +++ b/config/milo/iam/roles/audit-log-querier.yaml @@ -1,13 +1,16 @@ -apiVersion: iam.miloapis.com/v1alpha1 -kind: Role -metadata: - name: activity.miloapis.com-audit-log-querier - namespace: milo-system - labels: - app.kubernetes.io/name: audit-log-querier - app.kubernetes.io/part-of: activity.miloapis.com -spec: - launchStage: Beta - includedPermissions: - - activity.miloapis.com/auditlogqueries.create - - activity.miloapis.com/auditlogfacetsqueries.create +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: activity.miloapis.com-audit-log-querier + namespace: milo-system + annotations: + kubernetes.io/display-name: Audit Log Querier + kubernetes.io/description: Query audit logs and audit log facets + labels: + app.kubernetes.io/name: audit-log-querier + app.kubernetes.io/part-of: activity.miloapis.com +spec: + launchStage: Beta + includedPermissions: + - activity.miloapis.com/auditlogqueries.create + - activity.miloapis.com/auditlogfacetsqueries.create diff --git a/config/milo/iam/roles/event-viewer.yaml b/config/milo/iam/roles/event-viewer.yaml index 06912ab2..318e7905 100644 --- a/config/milo/iam/roles/event-viewer.yaml +++ b/config/milo/iam/roles/event-viewer.yaml @@ -1,17 +1,20 @@ -apiVersion: iam.miloapis.com/v1alpha1 -kind: Role -metadata: - name: activity.miloapis.com-event-viewer - namespace: milo-system - labels: - app.kubernetes.io/name: event-viewer - app.kubernetes.io/part-of: activity.miloapis.com -spec: - launchStage: Beta - includedPermissions: - # Event feed - list and watch for real-time streaming - - events.k8s.io/events.list - - events.k8s.io/events.watch - # Event queries - search cluster events - - activity.miloapis.com/eventqueries.create - - activity.miloapis.com/eventfacetqueries.create +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: activity.miloapis.com-event-viewer + namespace: milo-system + annotations: + kubernetes.io/display-name: Event Viewer + kubernetes.io/description: View event feeds and query cluster events and facets + labels: + app.kubernetes.io/name: event-viewer + app.kubernetes.io/part-of: activity.miloapis.com +spec: + launchStage: Beta + includedPermissions: + # Event feed - list and watch for real-time streaming + - events.k8s.io/events.list + - events.k8s.io/events.watch + # Event queries - search cluster events + - activity.miloapis.com/eventqueries.create + - activity.miloapis.com/eventfacetqueries.create diff --git a/config/milo/iam/roles/policy-manager.yaml b/config/milo/iam/roles/policy-manager.yaml index db2ba4a6..dbbf3bdc 100644 --- a/config/milo/iam/roles/policy-manager.yaml +++ b/config/milo/iam/roles/policy-manager.yaml @@ -1,21 +1,24 @@ -apiVersion: iam.miloapis.com/v1alpha1 -kind: Role -metadata: - name: activity.miloapis.com-policy-manager - namespace: milo-system - labels: - app.kubernetes.io/name: policy-manager - app.kubernetes.io/part-of: activity.miloapis.com -spec: - launchStage: Beta - includedPermissions: - # ActivityPolicy management - full CRUD operations - - activity.miloapis.com/activitypolicies.list - - activity.miloapis.com/activitypolicies.get - - activity.miloapis.com/activitypolicies.create - - activity.miloapis.com/activitypolicies.update - - activity.miloapis.com/activitypolicies.patch - - activity.miloapis.com/activitypolicies.delete - - activity.miloapis.com/activitypolicies.watch - # PolicyPreview - for testing policies before applying - - activity.miloapis.com/policypreviews.create +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: activity.miloapis.com-policy-manager + namespace: milo-system + annotations: + kubernetes.io/display-name: Activity Policy Manager + kubernetes.io/description: Full access to activity policies and policy previews + labels: + app.kubernetes.io/name: policy-manager + app.kubernetes.io/part-of: activity.miloapis.com +spec: + launchStage: Beta + includedPermissions: + # ActivityPolicy management - full CRUD operations + - activity.miloapis.com/activitypolicies.list + - activity.miloapis.com/activitypolicies.get + - activity.miloapis.com/activitypolicies.create + - activity.miloapis.com/activitypolicies.update + - activity.miloapis.com/activitypolicies.patch + - activity.miloapis.com/activitypolicies.delete + - activity.miloapis.com/activitypolicies.watch + # PolicyPreview - for testing policies before applying + - activity.miloapis.com/policypreviews.create diff --git a/config/milo/iam/roles/viewer.yaml b/config/milo/iam/roles/viewer.yaml index 0e0ca1f7..a0877d3a 100644 --- a/config/milo/iam/roles/viewer.yaml +++ b/config/milo/iam/roles/viewer.yaml @@ -1,17 +1,20 @@ -apiVersion: iam.miloapis.com/v1alpha1 -kind: Role -metadata: - name: activity.miloapis.com-viewer - namespace: milo-system - labels: - app.kubernetes.io/name: viewer - app.kubernetes.io/part-of: activity.miloapis.com -spec: - launchStage: Beta - inheritedRoles: - - name: activity.miloapis.com-activity-viewer - namespace: milo-system - - name: activity.miloapis.com-event-viewer - namespace: milo-system - - name: activity.miloapis.com-audit-log-querier - namespace: milo-system +apiVersion: iam.miloapis.com/v1alpha1 +kind: Role +metadata: + name: activity.miloapis.com-viewer + namespace: milo-system + annotations: + kubernetes.io/display-name: Viewer + kubernetes.io/description: View all activity data including activities, events, and audit logs + labels: + app.kubernetes.io/name: viewer + app.kubernetes.io/part-of: activity.miloapis.com +spec: + launchStage: Beta + inheritedRoles: + - name: activity.miloapis.com-activity-viewer + namespace: milo-system + - name: activity.miloapis.com-event-viewer + namespace: milo-system + - name: activity.miloapis.com-audit-log-querier + namespace: milo-system diff --git a/config/milo/kustomization.yaml b/config/milo/kustomization.yaml index da542f94..4d5fbfef 100644 --- a/config/milo/kustomization.yaml +++ b/config/milo/kustomization.yaml @@ -1,5 +1,5 @@ -apiVersion: kustomize.config.k8s.io/v1alpha1 -kind: Component - -components: - - iam/ +apiVersion: kustomize.config.k8s.io/v1alpha1 +kind: Component + +components: + - iam/ diff --git a/config/overlays/dev/kustomization.yaml b/config/overlays/dev/kustomization.yaml index 743a662e..e35bc171 100644 --- a/config/overlays/dev/kustomization.yaml +++ b/config/overlays/dev/kustomization.yaml @@ -1,77 +1,77 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: activity-system - -# Base resources (API server, service, APIService, etc.) -resources: - - ../../base - - anonymous-rbac.yaml # Dev-only: anonymous access for Gateway - -components: - - ../../components/namespace - - ../../components/api-registration - - ../../components/cert-manager-ca - - ../../components/etcd # Single-node etcd for ActivityPolicy storage - - ../../components/clickhouse-keeper-standalone # Single-node Keeper for coordination - - ../../components/clickhouse-standalone # Single-replica ClickHouse - - ../../components/clickhouse-migrations - - ../../components/nats-streams # NATS JetStream streams and consumers - - ../../components/grafana-clickhouse - - ../../components/vector-sidecar - - ../../components/vector-aggregator - - ../../components/k8s-event-exporter # Kubernetes events → NATS - - ../../components/tracing - - ../../components/observability # ServiceMonitors, alerts, dashboards - - ../../components/ui - -# Note: The following HA components are excluded for dev environments: -# - clickhouse-keeper # Replaced by clickhouse-keeper-standalone (1 replica) -# - clickhouse-database # Replaced by clickhouse-standalone (1 replica) -# - rustfs-bucket # No S3 cold storage in dev (uses local disk for both hot/cold) - -# Image overrides for dev environment -images: - - name: ghcr.io/datum-cloud/activity - newName: ghcr.io/datum-cloud/activity - newTag: dev - - name: ghcr.io/datum-cloud/activity-ui - newName: ghcr.io/datum-cloud/activity-ui - newTag: dev - -# Replacements to sync ACTIVITY_IMAGE env var with the actual deployed image -# This ensures the controller-manager uses the same image for ReindexJobs -# NOTE: This must be in the overlay (not base) so it runs AFTER image transformation -replacements: - - source: - kind: Deployment - name: activity-controller-manager - fieldPath: spec.template.spec.containers.[name=manager].image - targets: - - select: - kind: Deployment - name: activity-controller-manager - fieldPaths: - - spec.template.spec.containers.[name=manager].env.[name=ACTIVITY_IMAGE].value - -# Patches specific to dev environment -patches: - - path: patches/deployment-patch.yaml - - path: patches/activity-processor-patch.yaml - - path: patches/controller-manager-patch.yaml - - path: patches/apiservice-patch.yaml - - path: patches/migration-job-patch.yaml - - path: patches/vector-sidecar-patch.yaml - - path: patches/vector-aggregator-patch.yaml - - target: - kind: Deployment - name: activity-ui - path: patches/ui-cert-patch.yaml - -labels: - # Note: includeSelectors is false because operators (ClickHouse, etcd) create pods - # without kustomize labels, causing service selector mismatches - - includeSelectors: false - includeTemplates: true - pairs: - environment: dev +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: activity-system + +# Base resources (API server, service, APIService, etc.) +resources: + - ../../base + - anonymous-rbac.yaml # Dev-only: anonymous access for Gateway + +components: + - ../../components/namespace + - ../../components/api-registration + - ../../components/cert-manager-ca + - ../../components/etcd # Single-node etcd for ActivityPolicy storage + - ../../components/clickhouse-keeper-standalone # Single-node Keeper for coordination + - ../../components/clickhouse-standalone # Single-replica ClickHouse + - ../../components/clickhouse-migrations + - ../../components/nats-streams # NATS JetStream streams and consumers + - ../../components/grafana-clickhouse + - ../../components/vector-sidecar + - ../../components/vector-aggregator + - ../../components/k8s-event-exporter # Kubernetes events → NATS + - ../../components/tracing + - ../../components/observability # ServiceMonitors, alerts, dashboards + - ../../components/ui + +# Note: The following HA components are excluded for dev environments: +# - clickhouse-keeper # Replaced by clickhouse-keeper-standalone (1 replica) +# - clickhouse-database # Replaced by clickhouse-standalone (1 replica) +# - rustfs-bucket # No S3 cold storage in dev (uses local disk for both hot/cold) + +# Image overrides for dev environment +images: + - name: ghcr.io/datum-cloud/activity + newName: ghcr.io/datum-cloud/activity + newTag: dev + - name: ghcr.io/datum-cloud/activity-ui + newName: ghcr.io/datum-cloud/activity-ui + newTag: dev + +# Replacements to sync ACTIVITY_IMAGE env var with the actual deployed image +# This ensures the controller-manager uses the same image for ReindexJobs +# NOTE: This must be in the overlay (not base) so it runs AFTER image transformation +replacements: + - source: + kind: Deployment + name: activity-controller-manager + fieldPath: spec.template.spec.containers.[name=manager].image + targets: + - select: + kind: Deployment + name: activity-controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].env.[name=ACTIVITY_IMAGE].value + +# Patches specific to dev environment +patches: + - path: patches/deployment-patch.yaml + - path: patches/activity-processor-patch.yaml + - path: patches/controller-manager-patch.yaml + - path: patches/apiservice-patch.yaml + - path: patches/migration-job-patch.yaml + - path: patches/vector-sidecar-patch.yaml + - path: patches/vector-aggregator-patch.yaml + - target: + kind: Deployment + name: activity-ui + path: patches/ui-cert-patch.yaml + +labels: + # Note: includeSelectors is false because operators (ClickHouse, etcd) create pods + # without kustomize labels, causing service selector mismatches + - includeSelectors: false + includeTemplates: true + pairs: + environment: dev diff --git a/config/overlays/dev/patches/deployment-patch.yaml b/config/overlays/dev/patches/deployment-patch.yaml index 8312c352..4a83f4d6 100644 --- a/config/overlays/dev/patches/deployment-patch.yaml +++ b/config/overlays/dev/patches/deployment-patch.yaml @@ -1,31 +1,31 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-apiserver - namespace: activity-system -spec: - replicas: 1 - template: - spec: - containers: - - name: apiserver - # Dev-specific configuration - imagePullPolicy: Never # Use images loaded via kind load - env: - - name: LOG_LEVEL - value: "4" # Verbosity level: 0=info, 2=debug, 4=trace - - name: ENABLE_DEBUG_ENDPOINTS - value: "true" - # Enable Watch API via NATS - - name: ACTIVITIES_NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - - name: EVENTS_NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - resources: - # Reduced resources for dev environments - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 250m - memory: 256Mi +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-apiserver + namespace: activity-system +spec: + replicas: 1 + template: + spec: + containers: + - name: apiserver + # Dev-specific configuration + imagePullPolicy: Never # Use images loaded via kind load + env: + - name: LOG_LEVEL + value: "4" # Verbosity level: 0=info, 2=debug, 4=trace + - name: ENABLE_DEBUG_ENDPOINTS + value: "true" + # Enable Watch API via NATS + - name: ACTIVITIES_NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + - name: EVENTS_NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + resources: + # Reduced resources for dev environments + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi diff --git a/config/overlays/test-infra/kustomization.yaml b/config/overlays/test-infra/kustomization.yaml index 104f935b..7537213b 100644 --- a/config/overlays/test-infra/kustomization.yaml +++ b/config/overlays/test-infra/kustomization.yaml @@ -1,62 +1,62 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -namespace: activity-system - -# Base resources (API server, service, APIService, etc.) -resources: - - ../../base - -components: - - ../../components/namespace - - ../../components/api-registration - - ../../components/cert-manager-ca - - ../../components/etcd # etcd for ActivityPolicy storage - - ../../components/rustfs-bucket - - ../../components/clickhouse-keeper # Keeper coordination for HA - - ../../components/clickhouse-database # HA ClickHouse with 3 replicas - - ../../components/clickhouse-migrations - - ../../components/nats-streams # NATS JetStream streams and consumers - - ../../components/grafana-clickhouse - - ../../components/vector-sidecar - - ../../components/vector-aggregator - - ../../components/tracing -# Note: Vector sidecar is deployed to kube-system namespace -# Note: RustFS bucket initialization job will create the S3 bucket on first deployment - -# Image overrides for test-infra environment -images: - - name: ghcr.io/datum-cloud/activity - newName: ghcr.io/datum-cloud/activity - newTag: dev - -# Replacements to sync ACTIVITY_IMAGE env var with the actual deployed image -# This ensures the controller-manager uses the same image for ReindexJobs -# NOTE: This must be in the overlay (not base) so it runs AFTER image transformation -replacements: - - source: - kind: Deployment - name: activity-controller-manager - fieldPath: spec.template.spec.containers.[name=manager].image - targets: - - select: - kind: Deployment - name: activity-controller-manager - fieldPaths: - - spec.template.spec.containers.[name=manager].env.[name=ACTIVITY_IMAGE].value - -# Patches specific to test-infra environment -patches: - - path: patches/deployment-patch.yaml - - path: patches/apiservice-patch.yaml - - path: patches/vector-sidecar-patch.yaml - - path: patches/clickhouse-affinity-patch.yaml # Relaxed anti-affinity for single-node test cluster - - path: patches/clickhouse-storage-patch.yaml # RustFS storage configuration - -labels: - # Note: includeSelectors is false because operators (ClickHouse, etcd) create pods - # without kustomize labels, causing service selector mismatches - - includeSelectors: false - includeTemplates: true - pairs: - environment: test-infra +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +namespace: activity-system + +# Base resources (API server, service, APIService, etc.) +resources: + - ../../base + +components: + - ../../components/namespace + - ../../components/api-registration + - ../../components/cert-manager-ca + - ../../components/etcd # etcd for ActivityPolicy storage + - ../../components/rustfs-bucket + - ../../components/clickhouse-keeper # Keeper coordination for HA + - ../../components/clickhouse-database # HA ClickHouse with 3 replicas + - ../../components/clickhouse-migrations + - ../../components/nats-streams # NATS JetStream streams and consumers + - ../../components/grafana-clickhouse + - ../../components/vector-sidecar + - ../../components/vector-aggregator + - ../../components/tracing +# Note: Vector sidecar is deployed to kube-system namespace +# Note: RustFS bucket initialization job will create the S3 bucket on first deployment + +# Image overrides for test-infra environment +images: + - name: ghcr.io/datum-cloud/activity + newName: ghcr.io/datum-cloud/activity + newTag: dev + +# Replacements to sync ACTIVITY_IMAGE env var with the actual deployed image +# This ensures the controller-manager uses the same image for ReindexJobs +# NOTE: This must be in the overlay (not base) so it runs AFTER image transformation +replacements: + - source: + kind: Deployment + name: activity-controller-manager + fieldPath: spec.template.spec.containers.[name=manager].image + targets: + - select: + kind: Deployment + name: activity-controller-manager + fieldPaths: + - spec.template.spec.containers.[name=manager].env.[name=ACTIVITY_IMAGE].value + +# Patches specific to test-infra environment +patches: + - path: patches/deployment-patch.yaml + - path: patches/apiservice-patch.yaml + - path: patches/vector-sidecar-patch.yaml + - path: patches/clickhouse-affinity-patch.yaml # Relaxed anti-affinity for single-node test cluster + - path: patches/clickhouse-storage-patch.yaml # RustFS storage configuration + +labels: + # Note: includeSelectors is false because operators (ClickHouse, etcd) create pods + # without kustomize labels, causing service selector mismatches + - includeSelectors: false + includeTemplates: true + pairs: + environment: test-infra diff --git a/config/overlays/test-infra/patches/deployment-patch.yaml b/config/overlays/test-infra/patches/deployment-patch.yaml index ff55c96c..d517bba6 100644 --- a/config/overlays/test-infra/patches/deployment-patch.yaml +++ b/config/overlays/test-infra/patches/deployment-patch.yaml @@ -1,30 +1,30 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: activity-apiserver - namespace: activity-system -spec: - replicas: 1 - template: - spec: - containers: - - name: apiserver - # Test-infra specific configuration - imagePullPolicy: Never # Use images loaded via kind load - env: - - name: LOG_LEVEL - value: "4" # Verbosity level: 0=info, 2=debug, 4=trace - - name: ENABLE_DEBUG_ENDPOINTS - value: "true" - # Enable Watch API via NATS - - name: ACTIVITIES_NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - - name: EVENTS_NATS_URL - value: "nats://nats.nats-system.svc.cluster.local:4222" - resources: - requests: - cpu: 50m - memory: 64Mi - limits: - cpu: 250m - memory: 256Mi +apiVersion: apps/v1 +kind: Deployment +metadata: + name: activity-apiserver + namespace: activity-system +spec: + replicas: 1 + template: + spec: + containers: + - name: apiserver + # Test-infra specific configuration + imagePullPolicy: Never # Use images loaded via kind load + env: + - name: LOG_LEVEL + value: "4" # Verbosity level: 0=info, 2=debug, 4=trace + - name: ENABLE_DEBUG_ENDPOINTS + value: "true" + # Enable Watch API via NATS + - name: ACTIVITIES_NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + - name: EVENTS_NATS_URL + value: "nats://nats.nats-system.svc.cluster.local:4222" + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 250m + memory: 256Mi diff --git a/docs/api.md b/docs/api.md index dc248f5f..501b818c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,1041 +1,1041 @@ -# API Reference - -## Packages -- [activity.miloapis.com/v1alpha1](#activitymiloapiscomv1alpha1) - - -## activity.miloapis.com/v1alpha1 - -Package v1alpha1 contains API Schema definitions for the activity v1alpha1 API group - - - - - - - - - - - - - - - -#### Activity - - - -Activity is a human-readable summary of something that happened in your cluster. -Think of it as the "what changed and who did it" record that powers activity feeds, -audit trails, and change history views. - - -Activities are created automatically from audit logs and Kubernetes events based on -your ActivityPolicy rules. They're read-only - you query them, not create them. - - -# Accessing Activities - - -There are three ways to get activity data, depending on what you need: - - -| What you need | API to use | Notes | -| --- | --- | --- | -| Live feed | GET /activities?watch=true | Streams new activities as they happen. List only returns the last hour. | -| Search history | POST /activityqueries | Query any time range with filters, search, and pagination. | -| Filter options | POST /activityfacetqueries | Get values for dropdowns (e.g., "which actors have activities?"). | - - -# Quick Examples - - -Watch for new activities: - - - kubectl get activities --watch - - -List recent human-initiated changes: - - - kubectl get activities --field-selector spec.changeSource=human - - -For historical queries or advanced filtering, use ActivityQuery instead. - - - -_Appears in:_ -- [ActivityList](#activitylist) -- [ActivityQueryStatus](#activityquerystatus) -- [PolicyPreviewStatus](#policypreviewstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[ActivitySpec](#activityspec)_ | | | | - - -#### ActivityActor - - - -ActivityActor identifies who performed an action. - - - -_Appears in:_ -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _string_ | Type indicates the actor category.
Values: "user", "serviceaccount", "controller" | | | -| `name` _string_ | Name is the display name for the actor.
For users, this is typically the email address.
For service accounts, this is the full name (e.g., "system:serviceaccount:default:my-sa").
For controllers, this is the controller name. | | | -| `uid` _string_ | UID is the unique identifier for the actor.
Stable across username changes. | | | -| `email` _string_ | Email is the actor's email address.
Only populated for user actors when available. | | | - - -#### ActivityChange - - - -ActivityChange represents a field-level change in an update/patch operation. - - - -_Appears in:_ -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `field` _string_ | Field is the path to the changed field (e.g., "spec.virtualhost.fqdn"). | | | -| `old` _string_ | Old is the previous value. May be empty for new fields. | | | -| `new` _string_ | New is the new value. May be empty for deleted fields. | | | - - - - -#### ActivityFacetQuerySpec - - - -ActivityFacetQuerySpec defines what you want facet data for. - - - -_Appears in:_ -- [ActivityFacetQuery](#activityfacetquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange sets how far back to look. Defaults to the last 7 days if not set.
Use relative times like "now-7d" or absolute timestamps. | | | -| `filter` _string_ | Filter lets you narrow down which activities to include before computing facets.
Uses CEL (Common Expression Language) syntax.

This is useful when you want facet values for a specific subset - for example,
"show me actors, but only for human-initiated changes."

Fields you can filter on:
spec.changeSource - "human" or "system"
spec.actor.name - who did it (e.g., "alice@example.com")
spec.actor.type - user, serviceaccount, or controller
spec.resource.kind - what type of resource (Deployment, Pod, etc.)
spec.resource.namespace - which namespace
spec.resource.name - resource name
spec.resource.apiGroup - API group (empty string for core resources)

Example filters:
"spec.changeSource == 'human'" - Only human actions
"spec.resource.kind == 'Deployment'" - Only Deployment changes
"!spec.actor.name.startsWith('system:')" - Exclude system accounts | | | -| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts. | | | - - -#### ActivityFacetQueryStatus - - - -ActivityFacetQueryStatus contains the facet results. - - - -_Appears in:_ -- [ActivityFacetQuery](#activityfacetquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | - - -#### ActivityLink - - - -ActivityLink represents a clickable reference in an activity summary. - - - -_Appears in:_ -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `marker` _string_ | Marker is the text substring in the summary that should be linked.
The portal scans the summary for this marker and makes it clickable.

Example: "HTTP proxy api-gateway" | | | -| `resource` _[ActivityResource](#activityresource)_ | Resource identifies what the marker links to. | | | - - - - -#### ActivityOrigin - - - -ActivityOrigin identifies the source record for an activity. - - - -_Appears in:_ -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _string_ | Type indicates the source type.
Values: "audit" (from audit logs), "event" (from Kubernetes events) | | | -| `id` _string_ | ID is the correlation ID to the source record.
For audit: the auditID from the audit log entry.
For event: the metadata.uid of the Kubernetes Event. | | | - - -#### ActivityPolicy - - - -ActivityPolicy defines translation rules for a specific resource type. Service providers -create one ActivityPolicy per resource kind to customize activity descriptions without -modifying the Activity Processor. - - -Example: - - - apiVersion: activity.miloapis.com/v1alpha1 - kind: ActivityPolicy - metadata: - name: networking-httpproxy - spec: - resource: - apiGroup: networking.datumapis.com - kind: HTTPProxy - auditRules: - - match: "audit.verb == 'create'" - summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" - eventRules: - - match: "event.reason == 'Programmed'" - summary: "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" - - - -_Appears in:_ -- [ActivityPolicyList](#activitypolicylist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[ActivityPolicySpec](#activitypolicyspec)_ | | | | -| `status` _[ActivityPolicyStatus](#activitypolicystatus)_ | | | | - - - - -#### ActivityPolicyResource - - - -ActivityPolicyResource identifies the target Kubernetes resource for a policy. - - - -_Appears in:_ -- [ActivityPolicySpec](#activitypolicyspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiGroup` _string_ | APIGroup is the API group of the target resource (e.g., "networking.datumapis.com").
Use an empty string for core API group resources. | | | -| `kind` _string_ | Kind is the kind of the target resource (e.g., "HTTPProxy", "Network"). | | | - - -#### ActivityPolicyRule - - - -ActivityPolicyRule defines a single translation rule that matches input events -and generates human-readable activity summaries. - - - -_Appears in:_ -- [ActivityPolicySpec](#activitypolicyspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | Name is a unique identifier for this rule within the policy.
Used for strategic merge patching and error reporting. | | | -| `description` _string_ | Description is an optional human-readable description of what this rule does. | | | -| `match` _string_ | Match is a CEL expression that determines if this rule applies to the input.
For audit rules, use the `audit` variable (e.g., "audit.verb == 'create'", "audit.objectRef.namespace == 'default'").
For event rules, use the `event` variable (e.g., "event.reason == 'Programmed'").

Examples:
"audit.verb == 'create'"
"audit.verb in ['update', 'patch']"
"event.reason.startsWith('Failed')"
"true" (fallback rule that always matches) | | | -| `summary` _string_ | Summary is a CEL template for generating the activity summary.
Use \{\{ \}\} delimiters to embed CEL expressions within strings.

Available variables:
- For audit rules: audit (map), actor, actorRef, kind
Access audit fields via: audit.verb, audit.objectRef, audit.user, audit.responseStatus, audit.responseObject
- For event rules: event, actor, actorRef

Available functions:
- link(displayText, resourceRef): Creates a clickable reference

Examples:
"\{\{ actor \}\} created \{\{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) \}\}"
"\{\{ link(kind + ' ' + event.regarding.name, event.regarding) \}\} is now programmed" | | | - - -#### ActivityPolicySpec - - - -ActivityPolicySpec defines the translation rules for a resource type. - - - -_Appears in:_ -- [ActivityPolicy](#activitypolicy) -- [PolicyPreviewSpec](#policypreviewspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `resource` _[ActivityPolicyResource](#activitypolicyresource)_ | Resource identifies the Kubernetes resource this policy applies to.
One ActivityPolicy should exist per resource kind. | | | -| `auditRules` _[ActivityPolicyRule](#activitypolicyrule) array_ | AuditRules define how to translate audit log entries into activity summaries.
Rules are evaluated in order; the first matching rule wins.
Available variables: audit (map with verb, objectRef, user, responseStatus,
responseObject, requestObject), actor, actorRef, kind | | | -| `eventRules` _[ActivityPolicyRule](#activitypolicyrule) array_ | EventRules define how to translate Kubernetes events into activity summaries.
Rules are evaluated in order; the first matching rule wins.
The `event` variable contains the full Kubernetes Event structure.
Convenience variables available: actor | | | - - -#### ActivityPolicyStatus - - - -ActivityPolicyStatus represents the current state of an ActivityPolicy. - - - -_Appears in:_ -- [ActivityPolicy](#activitypolicy) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the current state of the policy.
The "Ready" condition indicates whether all rules compile successfully. | | | -| `observedGeneration` _integer_ | ObservedGeneration is the generation last processed by the controller. | | | - - - - -#### ActivityQuerySpec - - - -ActivityQuerySpec defines the search parameters for activities. - - -Required: startTime and endTime define your search window. -Optional: filter (CEL expression), search, limit, continue. - - -CEL is the primary filtering mechanism. All dedicated filter fields have been -removed in favor of the expressive filter field. - - -Available CEL Fields: - - - spec.changeSource - "human" or "system" - spec.actor.name - who performed the action - spec.actor.type - "user", "serviceaccount", "controller" - spec.actor.uid - actor's unique identifier - spec.resource.apiGroup - resource API group (empty for core) - spec.resource.kind - resource kind (Deployment, Pod, etc.) - spec.resource.name - resource name - spec.resource.namespace - resource namespace - spec.resource.uid - resource UID - spec.summary - activity summary text - spec.origin.type - "audit" or "event" - metadata.namespace - activity namespace - - -CEL Filter Examples: - - - "spec.changeSource == 'human'" - "spec.resource.kind == 'Deployment'" - "spec.actor.name.contains('admin')" - "spec.resource.kind in ['Deployment', 'StatefulSet']" - "spec.resource.apiGroup == 'networking.datumapis.com'" - "spec.actor.uid == 'abc123'" - - - -_Appears in:_ -- [ActivityQuery](#activityquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-7d", "now-2h", "now-30m" (units: s, m, h, d, w)
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone) | | | -| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for current moment.
Must be greater than StartTime. | | | -| `filter` _string_ | Filter narrows results using CEL (Common Expression Language).

This is the primary filtering mechanism. See the ActivityQuerySpec godoc
for available fields and examples.

Operators: ==, !=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains() | | | -| `search` _string_ | Search performs full-text search on activity summaries.

Example: "created deployment" matches activities with those words in the summary. | | | -| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000. | | | -| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. Copy status.continue here to get the next page.
Keep all other parameters identical across paginated requests. | | | - - -#### ActivityQueryStatus - - - -ActivityQueryStatus contains the query results and pagination state. - - - -_Appears in:_ -- [ActivityQuery](#activityquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `results` _[Activity](#activity) array_ | Results contains matching activities, sorted newest-first. | | | -| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available. | | | -| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used (RFC3339 format).
Shows the resolved timestamp when relative times are used. | | | -| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used (RFC3339 format).
Shows the resolved timestamp when relative times are used. | | | - - -#### ActivityResource - - - -ActivityResource identifies the Kubernetes resource affected by an activity. - - - -_Appears in:_ -- [ActivityLink](#activitylink) -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiGroup` _string_ | APIGroup is the API group of the resource.
Empty string for core API group. | | | -| `apiVersion` _string_ | APIVersion is the API version of the resource. | | | -| `kind` _string_ | Kind is the kind of the resource. | | | -| `name` _string_ | Name is the name of the resource. | | | -| `namespace` _string_ | Namespace is the namespace of the resource.
Empty for cluster-scoped resources. | | | -| `uid` _string_ | UID is the unique identifier of the resource. | | | - - -#### ActivitySpec - - - -ActivitySpec contains the translated activity details. - - - -_Appears in:_ -- [Activity](#activity) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `summary` _string_ | Summary is the human-readable description of what happened.
Generated from ActivityPolicy templates.

Example: "alice created HTTP proxy api-gateway" | | | -| `changeSource` _string_ | ChangeSource indicates who initiated the change.
Used to filter human actions from system reconciliation noise.

Values:
- "human": User action via kubectl, API, or UI
- "system": Controller reconciliation, operator actions, scheduled jobs | | | -| `actor` _[ActivityActor](#activityactor)_ | Actor identifies who performed the action. | | | -| `resource` _[ActivityResource](#activityresource)_ | Resource identifies the Kubernetes resource that was affected. | | | -| `links` _[ActivityLink](#activitylink) array_ | Links contains clickable references found in the summary.
The portal uses these to make resource names in the summary clickable. | | | -| `tenant` _[ActivityTenant](#activitytenant)_ | Tenant identifies the scope for multi-tenant isolation. | | | -| `changes` _[ActivityChange](#activitychange) array_ | Changes contains field-level changes for update/patch operations.
Shows old and new values for modified fields.

NOTE: This field may be empty in the initial implementation.
Populating old values requires resource history lookups. | | | -| `origin` _[ActivityOrigin](#activityorigin)_ | Origin identifies the source record for correlation. | | | - - -#### ActivityTenant - - - -ActivityTenant identifies the scope for multi-tenant isolation. - - - -_Appears in:_ -- [ActivitySpec](#activityspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _string_ | Type is the scope level.
Values: "global", "organization", "project", "user" | | | -| `name` _string_ | Name is the tenant identifier within the scope type. | | | - - - - -#### AuditLogFacetsQuerySpec - - - -AuditLogFacetsQuerySpec defines which facets to retrieve from audit logs. - - - -_Appears in:_ -- [AuditLogFacetsQuery](#auditlogfacetsquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange limits the time window for facet aggregation.
If not specified, defaults to the last 7 days. | | | -| `filter` _string_ | Filter narrows the audit logs before computing facets using CEL.
This allows you to get facet values for a subset of audit logs.

Available Fields:
verb - API action: get, list, create, update, patch, delete, watch
user.username - who made the request (user or service account)
user.uid - unique user identifier
responseStatus.code - HTTP response code (200, 201, 404, 500, etc.)
objectRef.namespace - target resource namespace
objectRef.resource - resource type (pods, deployments, secrets, configmaps, etc.)
objectRef.apiGroup - API group of the resource
objectRef.name - specific resource name

Operators: ==, !=, <, >, <=, >=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains()

Examples:
"verb in ['create', 'update', 'delete']" - Facets for write operations only
"!(verb in ['get', 'list', 'watch'])" - Exclude read-only operations
"!user.username.startsWith('system:')" - Exclude system users
"objectRef.namespace == 'production'" - Facets for production namespace | | | -| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts.

Supported fields:
- verb: API action (get, list, create, update, patch, delete, watch)
- user.username: Actor display names
- user.uid: Unique user identifiers
- responseStatus.code: HTTP response codes
- objectRef.namespace: Namespaces
- objectRef.resource: Resource types
- objectRef.apiGroup: API groups | | | - - -#### AuditLogFacetsQueryStatus - - - -AuditLogFacetsQueryStatus contains the facet results. - - - -_Appears in:_ -- [AuditLogFacetsQuery](#auditlogfacetsquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | - - - - -#### AuditLogQuerySpec - - - -AuditLogQuerySpec defines the search parameters. - - -Required: startTime and endTime define your search window. -Optional: filter (narrow results), limit (page size, default 100), continue (pagination). - - -Performance: Smaller time ranges and specific filters perform better. The maximum time window -is typically 30 days. If your range is too large, you'll get an error with guidance on splitting -your query into smaller chunks. - - - -_Appears in:_ -- [AuditLogQuery](#auditlogquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for dashboards and recurring queries - they adjust automatically.
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone)
Use for historical analysis of specific time periods.

Examples:
"now-30d" → 30 days ago
"2024-06-15T14:30:00-05:00" → specific time with timezone offset | | | -| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for current moment.
Must be greater than StartTime.

Examples:
"now" → current time
"2024-01-02T00:00:00Z" → specific end point | | | -| `filter` _string_ | Filter narrows results using CEL (Common Expression Language). Leave empty to get all events.

Available Fields:
verb - API action: get, list, create, update, patch, delete, watch
auditID - unique event identifier
requestReceivedTimestamp - when the API server received the request (RFC3339 timestamp)
user.username - who made the request (user or service account)
user.uid - unique user identifier (stable across username changes)
responseStatus.code - HTTP response code (200, 201, 404, 500, etc.)
objectRef.namespace - target resource namespace
objectRef.resource - resource type (pods, deployments, secrets, configmaps, etc.)
objectRef.name - specific resource name

Operators: ==, !=, <, >, <=, >=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains()

Common Patterns:
"verb == 'delete'" - All deletions
"objectRef.namespace == 'production'" - Activity in production namespace
"verb in ['create', 'update', 'delete', 'patch']" - All write operations
"!(verb in ['get', 'list', 'watch'])" - Exclude read-only operations
"responseStatus.code >= 400" - Failed requests
"user.username.startsWith('system:serviceaccount:')" - Service account activity
"!user.username.startsWith('system:')" - Exclude system users
"user.uid == '550e8400-e29b-41d4-a716-446655440000'" - Specific user by UID
"objectRef.resource == 'secrets'" - Secret access
"verb == 'delete' && objectRef.namespace == 'production'" - Production deletions

Note: Use single quotes for strings. Field names are case-sensitive.
CEL reference: https://cel.dev | | | -| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000.

Use smaller values (10-50) for exploration, larger (500-1000) for data collection.
Use continue to fetch additional pages. | | | -| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. If status.continue is non-empty after a query,
copy that value here in a new query with identical parameters to get the next page.
Repeat until status.continue is empty.

Important: Keep all other parameters (startTime, endTime, filter, limit) identical
across paginated requests. The cursor is opaque - copy it exactly without modification. | | | - - -#### AuditLogQueryStatus - - - -AuditLogQueryStatus contains the query results and pagination state. - - - -_Appears in:_ -- [AuditLogQuery](#auditlogquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `results` _Event array_ | Results contains matching audit events, sorted newest-first.

Each event follows the Kubernetes audit.Event format with fields like:
verb, user.username, objectRef.\{namespace,resource,name\}, requestReceivedTimestamp,
stageTimestamp, responseStatus.code, requestObject, responseObject

Empty results? Try broadening your filter or time range.
Full documentation: https://kubernetes.io/docs/reference/config-api/apiserver-audit.v1/ | | | -| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available - copy this to spec.continue for the next page.
Empty means you have all results. | | | -| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used for this query (RFC3339 format).

When you use relative times like "now-7d", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried, especially
for auditing, debugging, or recreating queries with absolute timestamps.

Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z,
this will be "2025-12-10T12:00:00Z". | | | -| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used for this query (RFC3339 format).

When you use relative times like "now", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried.

Example: If you query with endTime="now" at 2025-12-17T12:00:00Z,
this will be "2025-12-17T12:00:00Z". | | | - - -#### AutoFetchSpec - - - -AutoFetchSpec configures automatic sample data retrieval. - - - -_Appears in:_ -- [PolicyPreviewSpec](#policypreviewspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `limit` _integer_ | Limit is the maximum number of sample inputs to fetch (default: 10, max: 50).
The API fetches up to this many audit logs and/or events. | 10 | Maximum: 50
Minimum: 1
| -| `timeRange` _string_ | TimeRange specifies how far back to look for samples (default: "24h").
Supports relative format: "1h", "24h", "7d", "30d" | 24h | | -| `sources` _string_ | Sources specifies which data sources to query: "audit", "events", or "both" (default: "both").
- "audit": Only fetch audit logs (only tests auditRules)
- "events": Only fetch Kubernetes events (only tests eventRules)
- "both": Fetch both types (tests all rules) | both | Enum: [audit events both]
| - - - - -#### EventFacetQuerySpec - - - -EventFacetQuerySpec defines which facets to retrieve from Kubernetes Events. - - - -_Appears in:_ -- [EventFacetQuery](#eventfacetquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange limits the time window for facet aggregation.
If not specified, defaults to the last 7 days. | | | -| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts.

Supported fields:
- regarding.kind: Resource kinds (Pod, Deployment, etc.)
- regarding.namespace: Namespaces of regarding objects
- reason: Event reasons (Scheduled, Pulled, Created, etc.)
- type: Event types (Normal, Warning)
- source.component: Source components (kubelet, scheduler, etc.)
- namespace: Event namespace | | | - - -#### EventFacetQueryStatus - - - -EventFacetQueryStatus contains the facet results. - - - -_Appears in:_ -- [EventFacetQuery](#eventfacetquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | - - -#### EventQuery - - - -EventQuery searches Kubernetes Events stored in ClickHouse. - - -Unlike the native Events list (limited to 24 hours), EventQuery supports -up to 60 days of history. Results are returned in the Status field, -ordered newest-first. - - -Quick Start: - - - apiVersion: activity.miloapis.com/v1alpha1 - kind: EventQuery - metadata: - name: recent-pod-failures - spec: - startTime: "now-7d" # last 7 days - endTime: "now" - namespace: "production" # optional: limit to namespace - fieldSelector: "type=Warning" # optional: standard K8s field selector - limit: 100 - - -Time Formats: -- Relative: "now-30d" (great for dashboards and recurring queries) -- Absolute: "2024-01-01T00:00:00Z" (great for historical analysis) - - - -_Appears in:_ -- [EventQueryList](#eventquerylist) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `spec` _[EventQuerySpec](#eventqueryspec)_ | | | | -| `status` _[EventQueryStatus](#eventquerystatus)_ | | | | - - - - -#### EventQuerySpec - - - -EventQuerySpec defines the search parameters. - - -Required: startTime and endTime define your search window (max 60 days). -Optional: namespace (limit to namespace), fieldSelector (standard K8s syntax), -limit (page size, default 100), continue (pagination). - - - -_Appears in:_ -- [EventQuery](#eventquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for dashboards and recurring queries - they adjust automatically.
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone)
Use for historical analysis of specific time periods.

Maximum lookback is 60 days from now.

Examples:
"now-7d" → 7 days ago
"2024-06-15T14:30:00-05:00" → specific time with timezone offset | | | -| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for the current moment.
Must be greater than StartTime.

Examples:
"now" → current time
"2024-01-02T00:00:00Z" → specific end point | | | -| `namespace` _string_ | Namespace limits results to events from a specific namespace.
Leave empty to query events across all namespaces. | | | -| `fieldSelector` _string_ | FieldSelector filters events using standard Kubernetes field selector syntax.

Supported Fields:
metadata.name - event name
metadata.namespace - event namespace
metadata.uid - event UID
regarding.apiVersion - regarding resource API version
regarding.kind - regarding resource kind (e.g., Pod, Deployment)
regarding.namespace - regarding resource namespace
regarding.name - regarding resource name
regarding.uid - regarding resource UID
regarding.fieldPath - regarding resource field path
reason - event reason (e.g., FailedMount, Pulled)
type - event type (Normal or Warning)
source.component - reporting component
source.host - reporting host
reportingComponent - reporting component (alias for source.component)
reportingInstance - reporting instance (alias for source.host)

Operators: = (or ==), !=
Multiple conditions: comma-separated (all must match)

Common Patterns:
"type=Warning" - Warning events only
"regarding.kind=Pod" - Events for pods
"reason=FailedMount" - Mount failure events
"regarding.name=my-pod,type=Warning" - Warnings for a specific pod | | | -| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000.

Use smaller values (10-50) for exploration, larger (500-1000) for data collection.
Use continue to fetch additional pages. | | | -| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. If status.continue is non-empty after a query,
copy that value here in a new query with identical parameters to get the next page.
Repeat until status.continue is empty.

Important: Keep all other parameters (startTime, endTime, namespace, fieldSelector,
limit) identical across paginated requests. The cursor is opaque - copy it exactly
without modification. | | | - - -#### EventQueryStatus - - - -EventQueryStatus contains the query results and pagination state. - - - -_Appears in:_ -- [EventQuery](#eventquery) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `results` _[EventRecord](#eventrecord) array_ | Results contains matching Kubernetes Events, sorted newest-first.

Each event follows the eventsv1.Event format with fields like:
regarding.\{kind,name,namespace\}, reason, note, type,
eventTime, series.count, reportingController

Empty results? Try broadening your field selector or time range. | | | -| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available - copy this to spec.continue for the next page.
Empty means you have all results. | | | -| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used for this query (RFC3339 format).

When you use relative times like "now-7d", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried, especially
for auditing, debugging, or recreating queries with absolute timestamps.

Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z,
this will be "2025-12-10T12:00:00Z". | | | -| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used for this query (RFC3339 format).

When you use relative times like "now", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried.

Example: If you query with endTime="now" at 2025-12-17T12:00:00Z,
this will be "2025-12-17T12:00:00Z". | | | - - -#### EventRecord - - - -EventRecord represents a Kubernetes Event returned in EventQuery results. -This is a wrapper type registered under activity.miloapis.com/v1alpha1 that -embeds the events.k8s.io/v1 Event to avoid OpenAPI GVK conflicts while -preserving full event data. - - - -_Appears in:_ -- [EventQueryStatus](#eventquerystatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | -| `event` _[Event](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#event-v1-events)_ | Event contains the full Kubernetes Event data in events.k8s.io/v1 format.
This includes fields like eventTime, regarding, note, type, reason,
reportingController, reportingInstance, series, and action. | | | - - -#### FacetResult - - - -FacetResult contains the distinct values for a single facet. - - - -_Appears in:_ -- [ActivityFacetQueryStatus](#activityfacetquerystatus) -- [AuditLogFacetsQueryStatus](#auditlogfacetsquerystatus) -- [EventFacetQueryStatus](#eventfacetquerystatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `field` _string_ | Field is the field path that was queried. | | | -| `values` _[FacetValue](#facetvalue) array_ | Values contains the distinct values and their counts. | | | - - -#### FacetSpec - - - -FacetSpec defines a single facet to retrieve. - - - -_Appears in:_ -- [ActivityFacetQuerySpec](#activityfacetqueryspec) -- [AuditLogFacetsQuerySpec](#auditlogfacetsqueryspec) -- [EventFacetQuerySpec](#eventfacetqueryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `field` _string_ | Field is the activity field path to get distinct values for.

Supported fields:
- spec.actor.name: Actor display names
- spec.actor.type: Actor types (user, serviceaccount, controller)
- spec.resource.apiGroup: API groups
- spec.resource.kind: Resource kinds
- spec.resource.namespace: Namespaces
- spec.changeSource: Change sources (human, system) | | | -| `limit` _integer_ | Limit is the maximum number of distinct values to return.
Default: 20, Maximum: 100. | | | - - -#### FacetTimeRange - - - -FacetTimeRange specifies the time window for facet queries. - - - -_Appears in:_ -- [ActivityFacetQuerySpec](#activityfacetqueryspec) -- [AuditLogFacetsQuerySpec](#auditlogfacetsqueryspec) -- [EventFacetQuerySpec](#eventfacetqueryspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `start` _string_ | Start is the beginning of the time window (inclusive).
Supports RFC3339 timestamps and relative times (e.g., "now-7d"). | | | -| `end` _string_ | End is the end of the time window (exclusive).
Supports RFC3339 timestamps and relative times. Defaults to "now". | | | - - -#### FacetValue - - - -FacetValue represents a single distinct value with its occurrence count. - - - -_Appears in:_ -- [FacetResult](#facetresult) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `value` _string_ | Value is the distinct field value. | | | -| `count` _integer_ | Count is the number of activities with this value. | | | - - - - -#### PolicyPreviewInput - - - -PolicyPreviewInput contains the sample input for policy testing. - - - -_Appears in:_ -- [PolicyPreviewSpec](#policypreviewspec) -- [PolicyPreviewStatus](#policypreviewstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `type` _string_ | Type indicates whether this is an audit log or event input.
Values: "audit", "event" | | | -| `audit` _[Event](#event)_ | Audit contains a sample audit log entry.
Required when Type is "audit". | | | -| `event` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#rawextension-runtime-pkg)_ | Event contains a sample Kubernetes event.
Required when Type is "event".
Uses RawExtension to allow flexible event structure. | | | - - -#### PolicyPreviewInputResult - - - -PolicyPreviewInputResult contains the result for a single input. - - - -_Appears in:_ -- [PolicyPreviewStatus](#policypreviewstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `inputIndex` _integer_ | InputIndex is the index of this input in spec.inputs (0-based). | | | -| `matched` _boolean_ | Matched indicates whether any rule matched this input. | | | -| `matchedRuleIndex` _integer_ | MatchedRuleIndex is the index of the rule that matched (0-based).
-1 if no rule matched. | | | -| `matchedRuleType` _string_ | MatchedRuleType indicates whether the matched rule was an audit or event rule.
Empty if no rule matched. | | | -| `matchedRuleName` _string_ | MatchedRuleName is the name of the rule that matched this input.
This is the value from the rule's Name field in the policy spec.
Empty if no rule matched. | | | -| `error` _string_ | Error contains any error message if evaluating this input failed.
This could be a CEL compilation error or evaluation error. | | | - - -#### PolicyPreviewSpec - - - -PolicyPreviewSpec defines the policy and inputs to test. - - - -_Appears in:_ -- [PolicyPreview](#policypreview) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `policy` _[ActivityPolicySpec](#activitypolicyspec)_ | Policy is the ActivityPolicy spec to test.
You can use the full spec from an existing policy or create a new one. | | | -| `inputs` _[PolicyPreviewInput](#policypreviewinput) array_ | Inputs contains sample audit logs and/or events to test against the policy.
Each input is evaluated independently and produces an Activity if a rule matches.
You can mix audit logs and events in the same request.
Optional when AutoFetch is specified. | | | -| `autoFetch` _[AutoFetchSpec](#autofetchspec)_ | AutoFetch automatically retrieves sample inputs based on the policy resource type.
When specified, the API queries recent audit logs and/or events matching the policy.
Mutually exclusive with manual inputs - only one should be provided. | | | -| `kindLabel` _string_ | KindLabel overrides the display label for the resource kind. | | | -| `kindLabelPlural` _string_ | KindLabelPlural overrides the plural display label. | | | - - -#### PolicyPreviewStatus - - - -PolicyPreviewStatus contains the preview results. - - - -_Appears in:_ -- [PolicyPreview](#policypreview) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `activities` _[Activity](#activity) array_ | Activities contains the rendered Activity objects for inputs that matched a rule.
The order corresponds to the order of matched inputs (not necessarily the input order).
Inputs that don't match any rule are not included here. | | | -| `results` _[PolicyPreviewInputResult](#policypreviewinputresult) array_ | Results contains detailed results for each input, in the same order as spec.inputs.
Use this to see which inputs matched and any errors that occurred. | | | -| `fetchedInputs` _[PolicyPreviewInput](#policypreviewinput) array_ | FetchedInputs contains the auto-fetched sample inputs (only present when autoFetch was used).
This allows clients to see what data was tested. | | | -| `error` _string_ | Error contains a general error message if the preview failed entirely.
Individual input errors are reported in results[].error. | | | - - -#### ReindexConfig - - - -ReindexConfig contains processing configuration options. - - - -_Appears in:_ -- [ReindexJobSpec](#reindexjobspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `batchSize` _integer_ | BatchSize is the number of events to process per batch.
Larger batches are faster but use more memory.
Default: 1000 | 1000 | Maximum: 10000
Minimum: 100
| -| `rateLimit` _integer_ | RateLimit is the maximum events per second to process.
Prevents overwhelming ClickHouse.
Default: 100 | 100 | Maximum: 1000
Minimum: 10
| -| `dryRun` _boolean_ | DryRun previews changes without writing activities.
Useful for estimating impact before execution.
Default: false | | | - - -#### ReindexJob - - - -ReindexJob triggers re-processing of historical audit logs and events through -current ActivityPolicy rules. Use this to fix policy bugs retroactively, add -coverage for new policies, or refine activity summaries after policy improvements. - - -ReindexJob is a one-shot resource: once completed or failed, it cannot be -re-run. Create a new ReindexJob for subsequent re-indexing operations. - - -KUBERNETES EVENT LIMITATION: - - -When a Kubernetes Event is updated (e.g., count incremented from 1 to 5), -it retains the same UID. Re-indexing will produce ONE activity per Event UID, -reflecting the Event's final state. Historical activity occurrences from earlier -Event states are lost. - - -Example: Event "pod-oom" fires 5 times (count=5) → Re-indexing produces 1 activity (not 5) - - -Mitigation: Scope re-indexing to audit logs only via spec.policySelector to -preserve activities from earlier Event occurrences. - - -Example: - - - kubectl apply -f - <Events outside this range are not processed. | | | -| `policySelector` _[ReindexPolicySelector](#reindexpolicyselector)_ | PolicySelector optionally limits re-indexing to specific policies.
If omitted, all active ActivityPolicies are evaluated. | | | -| `config` _[ReindexConfig](#reindexconfig)_ | Config contains processing configuration options. | | | -| `ttlSecondsAfterFinished` _integer_ | TTLSecondsAfterFinished limits the lifetime of a ReindexJob after it finishes
execution (either Succeeded or Failed). If set, the controller will delete the
ReindexJob resource after it has been in a terminal state for this many seconds.

This field is optional. If unset, completed jobs are retained indefinitely.

Example: Setting to 3600 (1 hour) allows users to inspect job results for an
hour after completion, after which the job is automatically cleaned up. | | Minimum: 0
| - - -#### ReindexJobStatus - - - -ReindexJobStatus represents the current state of a ReindexJob. - - - -_Appears in:_ -- [ReindexJob](#reindexjob) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `phase` _[ReindexJobPhase](#reindexjobphase)_ | Phase is the current lifecycle phase.
Values: Pending, Running, Succeeded, Failed | | | -| `message` _string_ | Message is a human-readable description of the current state. | | | -| `progress` _[ReindexProgress](#reindexprogress)_ | Progress contains detailed progress information. | | | -| `startedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | StartedAt is when processing began. | | | -| `completedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | CompletedAt is when processing finished (success or failure). | | | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the latest observations of the job's state. | | | - - -#### ReindexPolicySelector - - - -ReindexPolicySelector specifies which policies to include in re-indexing. - - - -_Appears in:_ -- [ReindexJobSpec](#reindexjobspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `names` _string array_ | Names is a list of ActivityPolicy names to include.
Mutually exclusive with MatchLabels. | | | -| `matchLabels` _object (keys:string, values:string)_ | MatchLabels selects policies by label.
Mutually exclusive with Names. | | | - - -#### ReindexProgress - - - -ReindexProgress contains detailed progress metrics. - - - -_Appears in:_ -- [ReindexJobStatus](#reindexjobstatus) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `totalEvents` _integer_ | TotalEvents is the estimated total events to process. | | | -| `processedEvents` _integer_ | ProcessedEvents is the number of events processed so far. | | | -| `activitiesGenerated` _integer_ | ActivitiesGenerated is the number of activities created. | | | -| `errors` _integer_ | Errors is the count of non-fatal errors encountered. | | | -| `currentBatch` _integer_ | CurrentBatch is the batch number currently being processed. | | | -| `totalBatches` _integer_ | TotalBatches is the estimated total number of batches. | | | - - -#### ReindexTimeRange - - - -ReindexTimeRange specifies the time window for re-indexing. - - - -_Appears in:_ -- [ReindexJobSpec](#reindexjobspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `startTime` _string_ | StartTime is the beginning of the time range (inclusive).
Must be within the ClickHouse retention window (60 days).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for recent time windows - they adjust automatically at job start.
- Absolute: "2026-02-01T00:00:00Z" (RFC3339 with timezone)
Use for specific historical time periods.

Examples:
"now-7d" → 7 days before job starts
"2026-02-25T00:00:00Z" → specific time with UTC
"2026-02-25T00:00:00-08:00" → specific time with timezone offset

Note: Relative times are resolved when the job STARTS processing,
not when the resource is created. This ensures consistent time ranges
even if the job is queued. | | | -| `endTime` _string_ | EndTime is the end of the time range (exclusive).
Defaults to "now" (job start time) if omitted.

Uses the same formats as StartTime.
Must be greater than StartTime.

Examples:
"now" → current time when job starts
"2026-03-01T00:00:00Z" → specific end point
"now-1h" → 1 hour before job starts | | | - - +# API Reference + +## Packages +- [activity.miloapis.com/v1alpha1](#activitymiloapiscomv1alpha1) + + +## activity.miloapis.com/v1alpha1 + +Package v1alpha1 contains API Schema definitions for the activity v1alpha1 API group + + + + + + + + + + + + + + + +#### Activity + + + +Activity is a human-readable summary of something that happened in your cluster. +Think of it as the "what changed and who did it" record that powers activity feeds, +audit trails, and change history views. + + +Activities are created automatically from audit logs and Kubernetes events based on +your ActivityPolicy rules. They're read-only - you query them, not create them. + + +# Accessing Activities + + +There are three ways to get activity data, depending on what you need: + + +| What you need | API to use | Notes | +| --- | --- | --- | +| Live feed | GET /activities?watch=true | Streams new activities as they happen. List only returns the last hour. | +| Search history | POST /activityqueries | Query any time range with filters, search, and pagination. | +| Filter options | POST /activityfacetqueries | Get values for dropdowns (e.g., "which actors have activities?"). | + + +# Quick Examples + + +Watch for new activities: + + + kubectl get activities --watch + + +List recent human-initiated changes: + + + kubectl get activities --field-selector spec.changeSource=human + + +For historical queries or advanced filtering, use ActivityQuery instead. + + + +_Appears in:_ +- [ActivityList](#activitylist) +- [ActivityQueryStatus](#activityquerystatus) +- [PolicyPreviewStatus](#policypreviewstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[ActivitySpec](#activityspec)_ | | | | + + +#### ActivityActor + + + +ActivityActor identifies who performed an action. + + + +_Appears in:_ +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type indicates the actor category.
Values: "user", "serviceaccount", "controller" | | | +| `name` _string_ | Name is the display name for the actor.
For users, this is typically the email address.
For service accounts, this is the full name (e.g., "system:serviceaccount:default:my-sa").
For controllers, this is the controller name. | | | +| `uid` _string_ | UID is the unique identifier for the actor.
Stable across username changes. | | | +| `email` _string_ | Email is the actor's email address.
Only populated for user actors when available. | | | + + +#### ActivityChange + + + +ActivityChange represents a field-level change in an update/patch operation. + + + +_Appears in:_ +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `field` _string_ | Field is the path to the changed field (e.g., "spec.virtualhost.fqdn"). | | | +| `old` _string_ | Old is the previous value. May be empty for new fields. | | | +| `new` _string_ | New is the new value. May be empty for deleted fields. | | | + + + + +#### ActivityFacetQuerySpec + + + +ActivityFacetQuerySpec defines what you want facet data for. + + + +_Appears in:_ +- [ActivityFacetQuery](#activityfacetquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange sets how far back to look. Defaults to the last 7 days if not set.
Use relative times like "now-7d" or absolute timestamps. | | | +| `filter` _string_ | Filter lets you narrow down which activities to include before computing facets.
Uses CEL (Common Expression Language) syntax.

This is useful when you want facet values for a specific subset - for example,
"show me actors, but only for human-initiated changes."

Fields you can filter on:
spec.changeSource - "human" or "system"
spec.actor.name - who did it (e.g., "alice@example.com")
spec.actor.type - user, serviceaccount, or controller
spec.resource.kind - what type of resource (Deployment, Pod, etc.)
spec.resource.namespace - which namespace
spec.resource.name - resource name
spec.resource.apiGroup - API group (empty string for core resources)

Example filters:
"spec.changeSource == 'human'" - Only human actions
"spec.resource.kind == 'Deployment'" - Only Deployment changes
"!spec.actor.name.startsWith('system:')" - Exclude system accounts | | | +| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts. | | | + + +#### ActivityFacetQueryStatus + + + +ActivityFacetQueryStatus contains the facet results. + + + +_Appears in:_ +- [ActivityFacetQuery](#activityfacetquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | + + +#### ActivityLink + + + +ActivityLink represents a clickable reference in an activity summary. + + + +_Appears in:_ +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `marker` _string_ | Marker is the text substring in the summary that should be linked.
The portal scans the summary for this marker and makes it clickable.

Example: "HTTP proxy api-gateway" | | | +| `resource` _[ActivityResource](#activityresource)_ | Resource identifies what the marker links to. | | | + + + + +#### ActivityOrigin + + + +ActivityOrigin identifies the source record for an activity. + + + +_Appears in:_ +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type indicates the source type.
Values: "audit" (from audit logs), "event" (from Kubernetes events) | | | +| `id` _string_ | ID is the correlation ID to the source record.
For audit: the auditID from the audit log entry.
For event: the metadata.uid of the Kubernetes Event. | | | + + +#### ActivityPolicy + + + +ActivityPolicy defines translation rules for a specific resource type. Service providers +create one ActivityPolicy per resource kind to customize activity descriptions without +modifying the Activity Processor. + + +Example: + + + apiVersion: activity.miloapis.com/v1alpha1 + kind: ActivityPolicy + metadata: + name: networking-httpproxy + spec: + resource: + apiGroup: networking.datumapis.com + kind: HTTPProxy + auditRules: + - match: "audit.verb == 'create'" + summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" + eventRules: + - match: "event.reason == 'Programmed'" + summary: "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" + + + +_Appears in:_ +- [ActivityPolicyList](#activitypolicylist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[ActivityPolicySpec](#activitypolicyspec)_ | | | | +| `status` _[ActivityPolicyStatus](#activitypolicystatus)_ | | | | + + + + +#### ActivityPolicyResource + + + +ActivityPolicyResource identifies the target Kubernetes resource for a policy. + + + +_Appears in:_ +- [ActivityPolicySpec](#activitypolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiGroup` _string_ | APIGroup is the API group of the target resource (e.g., "networking.datumapis.com").
Use an empty string for core API group resources. | | | +| `kind` _string_ | Kind is the kind of the target resource (e.g., "HTTPProxy", "Network"). | | | + + +#### ActivityPolicyRule + + + +ActivityPolicyRule defines a single translation rule that matches input events +and generates human-readable activity summaries. + + + +_Appears in:_ +- [ActivityPolicySpec](#activitypolicyspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name is a unique identifier for this rule within the policy.
Used for strategic merge patching and error reporting. | | | +| `description` _string_ | Description is an optional human-readable description of what this rule does. | | | +| `match` _string_ | Match is a CEL expression that determines if this rule applies to the input.
For audit rules, use the `audit` variable (e.g., "audit.verb == 'create'", "audit.objectRef.namespace == 'default'").
For event rules, use the `event` variable (e.g., "event.reason == 'Programmed'").

Examples:
"audit.verb == 'create'"
"audit.verb in ['update', 'patch']"
"event.reason.startsWith('Failed')"
"true" (fallback rule that always matches) | | | +| `summary` _string_ | Summary is a CEL template for generating the activity summary.
Use \{\{ \}\} delimiters to embed CEL expressions within strings.

Available variables:
- For audit rules: audit (map), actor, actorRef, kind
Access audit fields via: audit.verb, audit.objectRef, audit.user, audit.responseStatus, audit.responseObject
- For event rules: event, actor, actorRef

Available functions:
- link(displayText, resourceRef): Creates a clickable reference

Examples:
"\{\{ actor \}\} created \{\{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) \}\}"
"\{\{ link(kind + ' ' + event.regarding.name, event.regarding) \}\} is now programmed" | | | + + +#### ActivityPolicySpec + + + +ActivityPolicySpec defines the translation rules for a resource type. + + + +_Appears in:_ +- [ActivityPolicy](#activitypolicy) +- [PolicyPreviewSpec](#policypreviewspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `resource` _[ActivityPolicyResource](#activitypolicyresource)_ | Resource identifies the Kubernetes resource this policy applies to.
One ActivityPolicy should exist per resource kind. | | | +| `auditRules` _[ActivityPolicyRule](#activitypolicyrule) array_ | AuditRules define how to translate audit log entries into activity summaries.
Rules are evaluated in order; the first matching rule wins.
Available variables: audit (map with verb, objectRef, user, responseStatus,
responseObject, requestObject), actor, actorRef, kind | | | +| `eventRules` _[ActivityPolicyRule](#activitypolicyrule) array_ | EventRules define how to translate Kubernetes events into activity summaries.
Rules are evaluated in order; the first matching rule wins.
The `event` variable contains the full Kubernetes Event structure.
Convenience variables available: actor | | | + + +#### ActivityPolicyStatus + + + +ActivityPolicyStatus represents the current state of an ActivityPolicy. + + + +_Appears in:_ +- [ActivityPolicy](#activitypolicy) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the current state of the policy.
The "Ready" condition indicates whether all rules compile successfully. | | | +| `observedGeneration` _integer_ | ObservedGeneration is the generation last processed by the controller. | | | + + + + +#### ActivityQuerySpec + + + +ActivityQuerySpec defines the search parameters for activities. + + +Required: startTime and endTime define your search window. +Optional: filter (CEL expression), search, limit, continue. + + +CEL is the primary filtering mechanism. All dedicated filter fields have been +removed in favor of the expressive filter field. + + +Available CEL Fields: + + + spec.changeSource - "human" or "system" + spec.actor.name - who performed the action + spec.actor.type - "user", "serviceaccount", "controller" + spec.actor.uid - actor's unique identifier + spec.resource.apiGroup - resource API group (empty for core) + spec.resource.kind - resource kind (Deployment, Pod, etc.) + spec.resource.name - resource name + spec.resource.namespace - resource namespace + spec.resource.uid - resource UID + spec.summary - activity summary text + spec.origin.type - "audit" or "event" + metadata.namespace - activity namespace + + +CEL Filter Examples: + + + "spec.changeSource == 'human'" + "spec.resource.kind == 'Deployment'" + "spec.actor.name.contains('admin')" + "spec.resource.kind in ['Deployment', 'StatefulSet']" + "spec.resource.apiGroup == 'networking.datumapis.com'" + "spec.actor.uid == 'abc123'" + + + +_Appears in:_ +- [ActivityQuery](#activityquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-7d", "now-2h", "now-30m" (units: s, m, h, d, w)
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone) | | | +| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for current moment.
Must be greater than StartTime. | | | +| `filter` _string_ | Filter narrows results using CEL (Common Expression Language).

This is the primary filtering mechanism. See the ActivityQuerySpec godoc
for available fields and examples.

Operators: ==, !=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains() | | | +| `search` _string_ | Search performs full-text search on activity summaries.

Example: "created deployment" matches activities with those words in the summary. | | | +| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000. | | | +| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. Copy status.continue here to get the next page.
Keep all other parameters identical across paginated requests. | | | + + +#### ActivityQueryStatus + + + +ActivityQueryStatus contains the query results and pagination state. + + + +_Appears in:_ +- [ActivityQuery](#activityquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `results` _[Activity](#activity) array_ | Results contains matching activities, sorted newest-first. | | | +| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available. | | | +| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used (RFC3339 format).
Shows the resolved timestamp when relative times are used. | | | +| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used (RFC3339 format).
Shows the resolved timestamp when relative times are used. | | | + + +#### ActivityResource + + + +ActivityResource identifies the Kubernetes resource affected by an activity. + + + +_Appears in:_ +- [ActivityLink](#activitylink) +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiGroup` _string_ | APIGroup is the API group of the resource.
Empty string for core API group. | | | +| `apiVersion` _string_ | APIVersion is the API version of the resource. | | | +| `kind` _string_ | Kind is the kind of the resource. | | | +| `name` _string_ | Name is the name of the resource. | | | +| `namespace` _string_ | Namespace is the namespace of the resource.
Empty for cluster-scoped resources. | | | +| `uid` _string_ | UID is the unique identifier of the resource. | | | + + +#### ActivitySpec + + + +ActivitySpec contains the translated activity details. + + + +_Appears in:_ +- [Activity](#activity) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `summary` _string_ | Summary is the human-readable description of what happened.
Generated from ActivityPolicy templates.

Example: "alice created HTTP proxy api-gateway" | | | +| `changeSource` _string_ | ChangeSource indicates who initiated the change.
Used to filter human actions from system reconciliation noise.

Values:
- "human": User action via kubectl, API, or UI
- "system": Controller reconciliation, operator actions, scheduled jobs | | | +| `actor` _[ActivityActor](#activityactor)_ | Actor identifies who performed the action. | | | +| `resource` _[ActivityResource](#activityresource)_ | Resource identifies the Kubernetes resource that was affected. | | | +| `links` _[ActivityLink](#activitylink) array_ | Links contains clickable references found in the summary.
The portal uses these to make resource names in the summary clickable. | | | +| `tenant` _[ActivityTenant](#activitytenant)_ | Tenant identifies the scope for multi-tenant isolation. | | | +| `changes` _[ActivityChange](#activitychange) array_ | Changes contains field-level changes for update/patch operations.
Shows old and new values for modified fields.

NOTE: This field may be empty in the initial implementation.
Populating old values requires resource history lookups. | | | +| `origin` _[ActivityOrigin](#activityorigin)_ | Origin identifies the source record for correlation. | | | + + +#### ActivityTenant + + + +ActivityTenant identifies the scope for multi-tenant isolation. + + + +_Appears in:_ +- [ActivitySpec](#activityspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type is the scope level.
Values: "global", "organization", "project", "user" | | | +| `name` _string_ | Name is the tenant identifier within the scope type. | | | + + + + +#### AuditLogFacetsQuerySpec + + + +AuditLogFacetsQuerySpec defines which facets to retrieve from audit logs. + + + +_Appears in:_ +- [AuditLogFacetsQuery](#auditlogfacetsquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange limits the time window for facet aggregation.
If not specified, defaults to the last 7 days. | | | +| `filter` _string_ | Filter narrows the audit logs before computing facets using CEL.
This allows you to get facet values for a subset of audit logs.

Available Fields:
verb - API action: get, list, create, update, patch, delete, watch
user.username - who made the request (user or service account)
user.uid - unique user identifier
responseStatus.code - HTTP response code (200, 201, 404, 500, etc.)
objectRef.namespace - target resource namespace
objectRef.resource - resource type (pods, deployments, secrets, configmaps, etc.)
objectRef.apiGroup - API group of the resource
objectRef.name - specific resource name

Operators: ==, !=, <, >, <=, >=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains()

Examples:
"verb in ['create', 'update', 'delete']" - Facets for write operations only
"!(verb in ['get', 'list', 'watch'])" - Exclude read-only operations
"!user.username.startsWith('system:')" - Exclude system users
"objectRef.namespace == 'production'" - Facets for production namespace | | | +| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts.

Supported fields:
- verb: API action (get, list, create, update, patch, delete, watch)
- user.username: Actor display names
- user.uid: Unique user identifiers
- responseStatus.code: HTTP response codes
- objectRef.namespace: Namespaces
- objectRef.resource: Resource types
- objectRef.apiGroup: API groups | | | + + +#### AuditLogFacetsQueryStatus + + + +AuditLogFacetsQueryStatus contains the facet results. + + + +_Appears in:_ +- [AuditLogFacetsQuery](#auditlogfacetsquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | + + + + +#### AuditLogQuerySpec + + + +AuditLogQuerySpec defines the search parameters. + + +Required: startTime and endTime define your search window. +Optional: filter (narrow results), limit (page size, default 100), continue (pagination). + + +Performance: Smaller time ranges and specific filters perform better. The maximum time window +is typically 30 days. If your range is too large, you'll get an error with guidance on splitting +your query into smaller chunks. + + + +_Appears in:_ +- [AuditLogQuery](#auditlogquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for dashboards and recurring queries - they adjust automatically.
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone)
Use for historical analysis of specific time periods.

Examples:
"now-30d" → 30 days ago
"2024-06-15T14:30:00-05:00" → specific time with timezone offset | | | +| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for current moment.
Must be greater than StartTime.

Examples:
"now" → current time
"2024-01-02T00:00:00Z" → specific end point | | | +| `filter` _string_ | Filter narrows results using CEL (Common Expression Language). Leave empty to get all events.

Available Fields:
verb - API action: get, list, create, update, patch, delete, watch
auditID - unique event identifier
requestReceivedTimestamp - when the API server received the request (RFC3339 timestamp)
user.username - who made the request (user or service account)
user.uid - unique user identifier (stable across username changes)
responseStatus.code - HTTP response code (200, 201, 404, 500, etc.)
objectRef.namespace - target resource namespace
objectRef.resource - resource type (pods, deployments, secrets, configmaps, etc.)
objectRef.name - specific resource name

Operators: ==, !=, <, >, <=, >=, &&, \|\|, !, in
String Functions: startsWith(), endsWith(), contains()

Common Patterns:
"verb == 'delete'" - All deletions
"objectRef.namespace == 'production'" - Activity in production namespace
"verb in ['create', 'update', 'delete', 'patch']" - All write operations
"!(verb in ['get', 'list', 'watch'])" - Exclude read-only operations
"responseStatus.code >= 400" - Failed requests
"user.username.startsWith('system:serviceaccount:')" - Service account activity
"!user.username.startsWith('system:')" - Exclude system users
"user.uid == '550e8400-e29b-41d4-a716-446655440000'" - Specific user by UID
"objectRef.resource == 'secrets'" - Secret access
"verb == 'delete' && objectRef.namespace == 'production'" - Production deletions

Note: Use single quotes for strings. Field names are case-sensitive.
CEL reference: https://cel.dev | | | +| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000.

Use smaller values (10-50) for exploration, larger (500-1000) for data collection.
Use continue to fetch additional pages. | | | +| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. If status.continue is non-empty after a query,
copy that value here in a new query with identical parameters to get the next page.
Repeat until status.continue is empty.

Important: Keep all other parameters (startTime, endTime, filter, limit) identical
across paginated requests. The cursor is opaque - copy it exactly without modification. | | | + + +#### AuditLogQueryStatus + + + +AuditLogQueryStatus contains the query results and pagination state. + + + +_Appears in:_ +- [AuditLogQuery](#auditlogquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `results` _Event array_ | Results contains matching audit events, sorted newest-first.

Each event follows the Kubernetes audit.Event format with fields like:
verb, user.username, objectRef.\{namespace,resource,name\}, requestReceivedTimestamp,
stageTimestamp, responseStatus.code, requestObject, responseObject

Empty results? Try broadening your filter or time range.
Full documentation: https://kubernetes.io/docs/reference/config-api/apiserver-audit.v1/ | | | +| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available - copy this to spec.continue for the next page.
Empty means you have all results. | | | +| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used for this query (RFC3339 format).

When you use relative times like "now-7d", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried, especially
for auditing, debugging, or recreating queries with absolute timestamps.

Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z,
this will be "2025-12-10T12:00:00Z". | | | +| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used for this query (RFC3339 format).

When you use relative times like "now", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried.

Example: If you query with endTime="now" at 2025-12-17T12:00:00Z,
this will be "2025-12-17T12:00:00Z". | | | + + +#### AutoFetchSpec + + + +AutoFetchSpec configures automatic sample data retrieval. + + + +_Appears in:_ +- [PolicyPreviewSpec](#policypreviewspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `limit` _integer_ | Limit is the maximum number of sample inputs to fetch (default: 10, max: 50).
The API fetches up to this many audit logs and/or events. | 10 | Maximum: 50
Minimum: 1
| +| `timeRange` _string_ | TimeRange specifies how far back to look for samples (default: "24h").
Supports relative format: "1h", "24h", "7d", "30d" | 24h | | +| `sources` _string_ | Sources specifies which data sources to query: "audit", "events", or "both" (default: "both").
- "audit": Only fetch audit logs (only tests auditRules)
- "events": Only fetch Kubernetes events (only tests eventRules)
- "both": Fetch both types (tests all rules) | both | Enum: [audit events both]
| + + + + +#### EventFacetQuerySpec + + + +EventFacetQuerySpec defines which facets to retrieve from Kubernetes Events. + + + +_Appears in:_ +- [EventFacetQuery](#eventfacetquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `timeRange` _[FacetTimeRange](#facettimerange)_ | TimeRange limits the time window for facet aggregation.
If not specified, defaults to the last 7 days. | | | +| `facets` _[FacetSpec](#facetspec) array_ | Facets specifies which fields to get distinct values for.
Each facet returns the top N values with counts.

Supported fields:
- regarding.kind: Resource kinds (Pod, Deployment, etc.)
- regarding.namespace: Namespaces of regarding objects
- reason: Event reasons (Scheduled, Pulled, Created, etc.)
- type: Event types (Normal, Warning)
- source.component: Source components (kubelet, scheduler, etc.)
- namespace: Event namespace | | | + + +#### EventFacetQueryStatus + + + +EventFacetQueryStatus contains the facet results. + + + +_Appears in:_ +- [EventFacetQuery](#eventfacetquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `facets` _[FacetResult](#facetresult) array_ | Facets contains the results for each requested facet. | | | + + +#### EventQuery + + + +EventQuery searches Kubernetes Events stored in ClickHouse. + + +Unlike the native Events list (limited to 24 hours), EventQuery supports +up to 60 days of history. Results are returned in the Status field, +ordered newest-first. + + +Quick Start: + + + apiVersion: activity.miloapis.com/v1alpha1 + kind: EventQuery + metadata: + name: recent-pod-failures + spec: + startTime: "now-7d" # last 7 days + endTime: "now" + namespace: "production" # optional: limit to namespace + fieldSelector: "type=Warning" # optional: standard K8s field selector + limit: 100 + + +Time Formats: +- Relative: "now-30d" (great for dashboards and recurring queries) +- Absolute: "2024-01-01T00:00:00Z" (great for historical analysis) + + + +_Appears in:_ +- [EventQueryList](#eventquerylist) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[EventQuerySpec](#eventqueryspec)_ | | | | +| `status` _[EventQueryStatus](#eventquerystatus)_ | | | | + + + + +#### EventQuerySpec + + + +EventQuerySpec defines the search parameters. + + +Required: startTime and endTime define your search window (max 60 days). +Optional: namespace (limit to namespace), fieldSelector (standard K8s syntax), +limit (page size, default 100), continue (pagination). + + + +_Appears in:_ +- [EventQuery](#eventquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `startTime` _string_ | StartTime is the beginning of your search window (inclusive).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for dashboards and recurring queries - they adjust automatically.
- Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone)
Use for historical analysis of specific time periods.

Maximum lookback is 60 days from now.

Examples:
"now-7d" → 7 days ago
"2024-06-15T14:30:00-05:00" → specific time with timezone offset | | | +| `endTime` _string_ | EndTime is the end of your search window (exclusive).

Uses the same formats as StartTime. Commonly "now" for the current moment.
Must be greater than StartTime.

Examples:
"now" → current time
"2024-01-02T00:00:00Z" → specific end point | | | +| `namespace` _string_ | Namespace limits results to events from a specific namespace.
Leave empty to query events across all namespaces. | | | +| `fieldSelector` _string_ | FieldSelector filters events using standard Kubernetes field selector syntax.

Supported Fields:
metadata.name - event name
metadata.namespace - event namespace
metadata.uid - event UID
regarding.apiVersion - regarding resource API version
regarding.kind - regarding resource kind (e.g., Pod, Deployment)
regarding.namespace - regarding resource namespace
regarding.name - regarding resource name
regarding.uid - regarding resource UID
regarding.fieldPath - regarding resource field path
reason - event reason (e.g., FailedMount, Pulled)
type - event type (Normal or Warning)
source.component - reporting component
source.host - reporting host
reportingComponent - reporting component (alias for source.component)
reportingInstance - reporting instance (alias for source.host)

Operators: = (or ==), !=
Multiple conditions: comma-separated (all must match)

Common Patterns:
"type=Warning" - Warning events only
"regarding.kind=Pod" - Events for pods
"reason=FailedMount" - Mount failure events
"regarding.name=my-pod,type=Warning" - Warnings for a specific pod | | | +| `limit` _integer_ | Limit sets the maximum number of results per page.
Default: 100, Maximum: 1000.

Use smaller values (10-50) for exploration, larger (500-1000) for data collection.
Use continue to fetch additional pages. | | | +| `continue` _string_ | Continue is the pagination cursor for fetching additional pages.

Leave empty for the first page. If status.continue is non-empty after a query,
copy that value here in a new query with identical parameters to get the next page.
Repeat until status.continue is empty.

Important: Keep all other parameters (startTime, endTime, namespace, fieldSelector,
limit) identical across paginated requests. The cursor is opaque - copy it exactly
without modification. | | | + + +#### EventQueryStatus + + + +EventQueryStatus contains the query results and pagination state. + + + +_Appears in:_ +- [EventQuery](#eventquery) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `results` _[EventRecord](#eventrecord) array_ | Results contains matching Kubernetes Events, sorted newest-first.

Each event follows the eventsv1.Event format with fields like:
regarding.\{kind,name,namespace\}, reason, note, type,
eventTime, series.count, reportingController

Empty results? Try broadening your field selector or time range. | | | +| `continue` _string_ | Continue is the pagination cursor.
Non-empty means more results are available - copy this to spec.continue for the next page.
Empty means you have all results. | | | +| `effectiveStartTime` _string_ | EffectiveStartTime is the actual start time used for this query (RFC3339 format).

When you use relative times like "now-7d", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried, especially
for auditing, debugging, or recreating queries with absolute timestamps.

Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z,
this will be "2025-12-10T12:00:00Z". | | | +| `effectiveEndTime` _string_ | EffectiveEndTime is the actual end time used for this query (RFC3339 format).

When you use relative times like "now", this shows the exact timestamp that was
calculated. Useful for understanding exactly what time range was queried.

Example: If you query with endTime="now" at 2025-12-17T12:00:00Z,
this will be "2025-12-17T12:00:00Z". | | | + + +#### EventRecord + + + +EventRecord represents a Kubernetes Event returned in EventQuery results. +This is a wrapper type registered under activity.miloapis.com/v1alpha1 that +embeds the events.k8s.io/v1 Event to avoid OpenAPI GVK conflicts while +preserving full event data. + + + +_Appears in:_ +- [EventQueryStatus](#eventquerystatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `event` _[Event](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#event-v1-events)_ | Event contains the full Kubernetes Event data in events.k8s.io/v1 format.
This includes fields like eventTime, regarding, note, type, reason,
reportingController, reportingInstance, series, and action. | | | + + +#### FacetResult + + + +FacetResult contains the distinct values for a single facet. + + + +_Appears in:_ +- [ActivityFacetQueryStatus](#activityfacetquerystatus) +- [AuditLogFacetsQueryStatus](#auditlogfacetsquerystatus) +- [EventFacetQueryStatus](#eventfacetquerystatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `field` _string_ | Field is the field path that was queried. | | | +| `values` _[FacetValue](#facetvalue) array_ | Values contains the distinct values and their counts. | | | + + +#### FacetSpec + + + +FacetSpec defines a single facet to retrieve. + + + +_Appears in:_ +- [ActivityFacetQuerySpec](#activityfacetqueryspec) +- [AuditLogFacetsQuerySpec](#auditlogfacetsqueryspec) +- [EventFacetQuerySpec](#eventfacetqueryspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `field` _string_ | Field is the activity field path to get distinct values for.

Supported fields:
- spec.actor.name: Actor display names
- spec.actor.type: Actor types (user, serviceaccount, controller)
- spec.resource.apiGroup: API groups
- spec.resource.kind: Resource kinds
- spec.resource.namespace: Namespaces
- spec.changeSource: Change sources (human, system) | | | +| `limit` _integer_ | Limit is the maximum number of distinct values to return.
Default: 20, Maximum: 100. | | | + + +#### FacetTimeRange + + + +FacetTimeRange specifies the time window for facet queries. + + + +_Appears in:_ +- [ActivityFacetQuerySpec](#activityfacetqueryspec) +- [AuditLogFacetsQuerySpec](#auditlogfacetsqueryspec) +- [EventFacetQuerySpec](#eventfacetqueryspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `start` _string_ | Start is the beginning of the time window (inclusive).
Supports RFC3339 timestamps and relative times (e.g., "now-7d"). | | | +| `end` _string_ | End is the end of the time window (exclusive).
Supports RFC3339 timestamps and relative times. Defaults to "now". | | | + + +#### FacetValue + + + +FacetValue represents a single distinct value with its occurrence count. + + + +_Appears in:_ +- [FacetResult](#facetresult) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `value` _string_ | Value is the distinct field value. | | | +| `count` _integer_ | Count is the number of activities with this value. | | | + + + + +#### PolicyPreviewInput + + + +PolicyPreviewInput contains the sample input for policy testing. + + + +_Appears in:_ +- [PolicyPreviewSpec](#policypreviewspec) +- [PolicyPreviewStatus](#policypreviewstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `type` _string_ | Type indicates whether this is an audit log or event input.
Values: "audit", "event" | | | +| `audit` _[Event](#event)_ | Audit contains a sample audit log entry.
Required when Type is "audit". | | | +| `event` _[RawExtension](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#rawextension-runtime-pkg)_ | Event contains a sample Kubernetes event.
Required when Type is "event".
Uses RawExtension to allow flexible event structure. | | | + + +#### PolicyPreviewInputResult + + + +PolicyPreviewInputResult contains the result for a single input. + + + +_Appears in:_ +- [PolicyPreviewStatus](#policypreviewstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `inputIndex` _integer_ | InputIndex is the index of this input in spec.inputs (0-based). | | | +| `matched` _boolean_ | Matched indicates whether any rule matched this input. | | | +| `matchedRuleIndex` _integer_ | MatchedRuleIndex is the index of the rule that matched (0-based).
-1 if no rule matched. | | | +| `matchedRuleType` _string_ | MatchedRuleType indicates whether the matched rule was an audit or event rule.
Empty if no rule matched. | | | +| `matchedRuleName` _string_ | MatchedRuleName is the name of the rule that matched this input.
This is the value from the rule's Name field in the policy spec.
Empty if no rule matched. | | | +| `error` _string_ | Error contains any error message if evaluating this input failed.
This could be a CEL compilation error or evaluation error. | | | + + +#### PolicyPreviewSpec + + + +PolicyPreviewSpec defines the policy and inputs to test. + + + +_Appears in:_ +- [PolicyPreview](#policypreview) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `policy` _[ActivityPolicySpec](#activitypolicyspec)_ | Policy is the ActivityPolicy spec to test.
You can use the full spec from an existing policy or create a new one. | | | +| `inputs` _[PolicyPreviewInput](#policypreviewinput) array_ | Inputs contains sample audit logs and/or events to test against the policy.
Each input is evaluated independently and produces an Activity if a rule matches.
You can mix audit logs and events in the same request.
Optional when AutoFetch is specified. | | | +| `autoFetch` _[AutoFetchSpec](#autofetchspec)_ | AutoFetch automatically retrieves sample inputs based on the policy resource type.
When specified, the API queries recent audit logs and/or events matching the policy.
Mutually exclusive with manual inputs - only one should be provided. | | | +| `kindLabel` _string_ | KindLabel overrides the display label for the resource kind. | | | +| `kindLabelPlural` _string_ | KindLabelPlural overrides the plural display label. | | | + + +#### PolicyPreviewStatus + + + +PolicyPreviewStatus contains the preview results. + + + +_Appears in:_ +- [PolicyPreview](#policypreview) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `activities` _[Activity](#activity) array_ | Activities contains the rendered Activity objects for inputs that matched a rule.
The order corresponds to the order of matched inputs (not necessarily the input order).
Inputs that don't match any rule are not included here. | | | +| `results` _[PolicyPreviewInputResult](#policypreviewinputresult) array_ | Results contains detailed results for each input, in the same order as spec.inputs.
Use this to see which inputs matched and any errors that occurred. | | | +| `fetchedInputs` _[PolicyPreviewInput](#policypreviewinput) array_ | FetchedInputs contains the auto-fetched sample inputs (only present when autoFetch was used).
This allows clients to see what data was tested. | | | +| `error` _string_ | Error contains a general error message if the preview failed entirely.
Individual input errors are reported in results[].error. | | | + + +#### ReindexConfig + + + +ReindexConfig contains processing configuration options. + + + +_Appears in:_ +- [ReindexJobSpec](#reindexjobspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `batchSize` _integer_ | BatchSize is the number of events to process per batch.
Larger batches are faster but use more memory.
Default: 1000 | 1000 | Maximum: 10000
Minimum: 100
| +| `rateLimit` _integer_ | RateLimit is the maximum events per second to process.
Prevents overwhelming ClickHouse.
Default: 100 | 100 | Maximum: 1000
Minimum: 10
| +| `dryRun` _boolean_ | DryRun previews changes without writing activities.
Useful for estimating impact before execution.
Default: false | | | + + +#### ReindexJob + + + +ReindexJob triggers re-processing of historical audit logs and events through +current ActivityPolicy rules. Use this to fix policy bugs retroactively, add +coverage for new policies, or refine activity summaries after policy improvements. + + +ReindexJob is a one-shot resource: once completed or failed, it cannot be +re-run. Create a new ReindexJob for subsequent re-indexing operations. + + +KUBERNETES EVENT LIMITATION: + + +When a Kubernetes Event is updated (e.g., count incremented from 1 to 5), +it retains the same UID. Re-indexing will produce ONE activity per Event UID, +reflecting the Event's final state. Historical activity occurrences from earlier +Event states are lost. + + +Example: Event "pod-oom" fires 5 times (count=5) → Re-indexing produces 1 activity (not 5) + + +Mitigation: Scope re-indexing to audit logs only via spec.policySelector to +preserve activities from earlier Event occurrences. + + +Example: + + + kubectl apply -f - <Events outside this range are not processed. | | | +| `policySelector` _[ReindexPolicySelector](#reindexpolicyselector)_ | PolicySelector optionally limits re-indexing to specific policies.
If omitted, all active ActivityPolicies are evaluated. | | | +| `config` _[ReindexConfig](#reindexconfig)_ | Config contains processing configuration options. | | | +| `ttlSecondsAfterFinished` _integer_ | TTLSecondsAfterFinished limits the lifetime of a ReindexJob after it finishes
execution (either Succeeded or Failed). If set, the controller will delete the
ReindexJob resource after it has been in a terminal state for this many seconds.

This field is optional. If unset, completed jobs are retained indefinitely.

Example: Setting to 3600 (1 hour) allows users to inspect job results for an
hour after completion, after which the job is automatically cleaned up. | | Minimum: 0
| + + +#### ReindexJobStatus + + + +ReindexJobStatus represents the current state of a ReindexJob. + + + +_Appears in:_ +- [ReindexJob](#reindexjob) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `phase` _[ReindexJobPhase](#reindexjobphase)_ | Phase is the current lifecycle phase.
Values: Pending, Running, Succeeded, Failed | | | +| `message` _string_ | Message is a human-readable description of the current state. | | | +| `progress` _[ReindexProgress](#reindexprogress)_ | Progress contains detailed progress information. | | | +| `startedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | StartedAt is when processing began. | | | +| `completedAt` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#time-v1-meta)_ | CompletedAt is when processing finished (success or failure). | | | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the latest observations of the job's state. | | | + + +#### ReindexPolicySelector + + + +ReindexPolicySelector specifies which policies to include in re-indexing. + + + +_Appears in:_ +- [ReindexJobSpec](#reindexjobspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `names` _string array_ | Names is a list of ActivityPolicy names to include.
Mutually exclusive with MatchLabels. | | | +| `matchLabels` _object (keys:string, values:string)_ | MatchLabels selects policies by label.
Mutually exclusive with Names. | | | + + +#### ReindexProgress + + + +ReindexProgress contains detailed progress metrics. + + + +_Appears in:_ +- [ReindexJobStatus](#reindexjobstatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `totalEvents` _integer_ | TotalEvents is the estimated total events to process. | | | +| `processedEvents` _integer_ | ProcessedEvents is the number of events processed so far. | | | +| `activitiesGenerated` _integer_ | ActivitiesGenerated is the number of activities created. | | | +| `errors` _integer_ | Errors is the count of non-fatal errors encountered. | | | +| `currentBatch` _integer_ | CurrentBatch is the batch number currently being processed. | | | +| `totalBatches` _integer_ | TotalBatches is the estimated total number of batches. | | | + + +#### ReindexTimeRange + + + +ReindexTimeRange specifies the time window for re-indexing. + + + +_Appears in:_ +- [ReindexJobSpec](#reindexjobspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `startTime` _string_ | StartTime is the beginning of the time range (inclusive).
Must be within the ClickHouse retention window (60 days).

Format Options:
- Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w)
Use for recent time windows - they adjust automatically at job start.
- Absolute: "2026-02-01T00:00:00Z" (RFC3339 with timezone)
Use for specific historical time periods.

Examples:
"now-7d" → 7 days before job starts
"2026-02-25T00:00:00Z" → specific time with UTC
"2026-02-25T00:00:00-08:00" → specific time with timezone offset

Note: Relative times are resolved when the job STARTS processing,
not when the resource is created. This ensures consistent time ranges
even if the job is queued. | | | +| `endTime` _string_ | EndTime is the end of the time range (exclusive).
Defaults to "now" (job start time) if omitted.

Uses the same formats as StartTime.
Must be greater than StartTime.

Examples:
"now" → current time when job starts
"2026-03-01T00:00:00Z" → specific end point
"now-1h" → 1 hour before job starts | | | + + diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 7bf06dda..b48dcb14 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -1,296 +1,296 @@ -# Data Model - -The activity service stores data in a ClickHouse cluster managed by the -[Altinity ClickHouse Operator][ch-operator]. The cluster runs 3 replicas -coordinated by [ClickHouse Keeper][ch-keeper] for high availability. - -[ch-operator]: https://github.com/Altinity/clickhouse-operator -[ch-keeper]: https://clickhouse.com/docs/en/guides/sre/keeper/clickhouse-keeper - -## Storage Architecture - -All tables use `ReplicatedReplacingMergeTree` with: -- Daily or monthly partitioning for efficient TTL management -- Primary keys optimized for time-scoped tenant queries -- Bloom filter and minmax skip indexes for common filter patterns -- ZSTD compression for JSON columns - -### Retention Policies - -| Table | Retention | Storage | -|-------|-----------|---------| -| Audit Events | Unlimited | Hot (90 days) → Cold (S3) | -| Events | 60 days | Hot only | -| Activities | 60 days | Hot only | - -Audit logs use tiered storage: data automatically moves from local SSD to -S3-compatible cold storage after 90 days. A 10 GB local cache accelerates -queries against cold data. Events and activities are deleted after their -retention period expires. - -## Audit Events Table - -The `audit.events` table stores raw audit events from the control plane: - -```sql -CREATE TABLE audit.events ( - -- Full audit event as compressed JSON - event_json String CODEC(ZSTD(3)), - - -- Extracted timestamp for partitioning and ordering - timestamp DateTime64(6), - - -- Tenant scope - scope_type LowCardinality(String), -- Organization, Project, User - scope_name String, - - -- Actor identity - user String, - user_uid String, - - -- Request metadata - verb LowCardinality(String), - resource LowCardinality(String), - api_group LowCardinality(String), - namespace String, - resource_name String, - - -- Response - status_code UInt16 - -) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/audit_events', '{replica}') -PARTITION BY toYYYYMMDD(timestamp) -ORDER BY (scope_type, scope_name, timestamp, user_uid) -TTL timestamp + INTERVAL 90 DAY TO VOLUME 'cold' -SETTINGS index_granularity = 8192; -``` - -### Indexes - -| Index | Type | Columns | Purpose | -|-------|------|---------|---------| -| `idx_verb` | bloom_filter | `verb` | Filter by operation type | -| `idx_resource` | bloom_filter | `resource, resource_name` | Resource lookups | -| `idx_user` | bloom_filter | `user, user_uid` | Actor-based queries | -| `idx_status` | minmax | `status_code` | Error filtering | - -### Projections - -Three projections provide optimized sort orders: - -```sql --- Platform-wide queries (sorted by time) -PROJECTION platform_queries ( - SELECT * ORDER BY timestamp, scope_type, scope_name -) - --- Username-based queries -PROJECTION user_queries ( - SELECT * ORDER BY user, timestamp -) - --- User UID-based queries (stable across name changes) -PROJECTION uid_queries ( - SELECT * ORDER BY user_uid, timestamp -) -``` - -## Events Table - -The `audit.k8s_events` table stores Kubernetes Events (core/v1.Event) for -multi-tenant environments. - -### Storage Model - -Events use an **insert-only model** where each event state (as `lastTimestamp` -changes) becomes a separate row. This allows `last_timestamp` to be in the -primary key for efficient ordering. Queries use `LIMIT 1 BY uid` to get the -latest state per event. `ReplacingMergeTree` handles true duplicates from -pipeline retries. - -```sql -CREATE TABLE audit.k8s_events ( - -- Full event as compressed JSON - event_json String CODEC(ZSTD(3)), - - -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) - inserted_at DateTime64(9), - - -- Tenant scope (primary query dimension) - scope_type LowCardinality(String), -- Organization, Project - scope_name String, - - -- Timestamps - first_timestamp DateTime64(3), - last_timestamp DateTime64(3), - - -- Event metadata - namespace LowCardinality(String), - name String, - uid String, - - -- Regarding object (the resource the event is about) - regarding_api_group LowCardinality(String), -- e.g., "apps", "networking.k8s.io" - regarding_api_version LowCardinality(String), - regarding_kind LowCardinality(String), -- e.g., "Pod", "Deployment" - regarding_namespace LowCardinality(String), - regarding_name String, - regarding_uid String, - - -- Event classification - reason LowCardinality(String), -- e.g., "Scheduled", "Pulling", "Created" - type LowCardinality(String), -- "Normal" or "Warning" - - -- Source (what generated the event) - source_component LowCardinality(String), -- e.g., "kubelet", "deployment-controller" - source_host String - -) ENGINE = ReplicatedReplacingMergeTree(inserted_at) -PARTITION BY toYYYYMMDD(last_timestamp) -ORDER BY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) -TTL last_timestamp + INTERVAL 60 DAY DELETE; -``` - -### Example Query - -To get the latest state of each event, sorted by most recent activity: - -```sql -SELECT * FROM audit.k8s_events -WHERE scope_type = 'organization' AND scope_name = 'acme' -ORDER BY last_timestamp DESC -LIMIT 1 BY uid -``` - -### Query Patterns - -The table is optimized for four primary query patterns: - -1. **Multi-tenant queries** (default): Filter by scope, then time range -2. **API group/resource queries**: Find events for specific resource types -3. **Platform-wide queries**: Time-range queries across all tenants -4. **Source component queries**: Events from specific controllers - -### Indexes - -| Index | Type | Columns | Purpose | -|-------|------|---------|---------| -| `idx_scope_name_bloom` | bloom_filter | `scope_name` | Tenant filtering | -| `idx_regarding_api_group` | set | `regarding_api_group` | API group queries | -| `idx_regarding_kind_set` | set | `regarding_kind` | Resource type filtering | -| `idx_regarding_name_bloom` | bloom_filter | `regarding_name` | Resource name lookups | -| `idx_regarding_uid_bloom` | bloom_filter | `regarding_uid` | Resource UID lookups | -| `idx_reason_set` | set | `reason` | Event reason filtering | -| `idx_type_set` | set | `type` | Normal vs Warning | -| `idx_source_component` | set | `source_component` | Controller/component filtering | - -### Projections - -Three projections provide optimized sort orders: - -```sql --- Platform-wide queries (sorted by time across all tenants) -PROJECTION platform_query_projection ( - SELECT * ORDER BY (last_timestamp, scope_type, scope_name, - regarding_api_group, regarding_kind, type, uid) -) - --- API group/resource queries (sorted by regarding object type) -PROJECTION regarding_object_query_projection ( - SELECT * ORDER BY (regarding_api_group, regarding_kind, scope_type, - scope_name, last_timestamp, type, uid) -) - --- Source component queries (by generating controller/component) -PROJECTION source_query_projection ( - SELECT * ORDER BY (source_component, last_timestamp, scope_type, - scope_name, regarding_api_group, regarding_kind, type, uid) -) -``` - -## Activities Table - -The `activity.activities` table stores translated activity records: - -```sql -CREATE TABLE activity.activities ( - -- Full activity as compressed JSON - activity_json String CODEC(ZSTD(3)), - - -- Extracted timestamp - timestamp DateTime64(6), - - -- Tenant scope - tenant_type LowCardinality(String), -- global, organization, project, user - tenant_name String, - - -- Origin tracking - origin_type LowCardinality(String), -- audit, event - origin_id String, - - -- Change classification - change_source LowCardinality(String), -- human, system - - -- Actor - actor_type LowCardinality(String), -- user, serviceaccount, controller - actor_name String, - actor_uid String, - - -- Affected resource - api_group LowCardinality(String), - resource_kind LowCardinality(String), - resource_name String, - resource_namespace String, - resource_uid String, - - -- Human-readable summary for full-text search - summary String - -) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/activities', '{replica}') -PARTITION BY toYYYYMMDD(timestamp) -ORDER BY (tenant_type, tenant_name, timestamp, resource_uid) -TTL timestamp + INTERVAL 30 DAY -SETTINGS index_granularity = 8192; -``` - -### Indexes - -| Index | Type | Columns | Purpose | -|-------|------|---------|---------| -| `idx_api_group` | bloom_filter | `api_group` | Service provider queries | -| `idx_actor` | bloom_filter | `actor_name` | Actor-based filtering | -| `idx_resource` | bloom_filter | `resource_kind, resource_name` | Resource lookups | -| `idx_change_source` | minmax | `change_source` | Human vs system filtering | -| `idx_summary_search` | tokenbf_v1 | `summary` | Full-text search | - -### Full-Text Search - -The `idx_summary_search` index uses ClickHouse's `tokenbf_v1` bloom filter for -efficient token matching: - -```sql -INDEX idx_summary_search summary TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4 -``` - -This enables queries like: - -```sql -SELECT * FROM activities -WHERE hasToken(summary, 'HTTPProxy') - AND hasToken(summary, 'created') -``` - -## Write Consistency - -All tables are configured for strong consistency: - -- Writes require acknowledgment from 2 of 3 replicas before returning success -- Reads use sequential consistency for read-after-write guarantees -- 7-day deduplication windows prevent duplicate records from pipeline retries - -## Related Documentation - -- [Architecture Overview](./README.md) -- [Audit Pipeline](./audit-pipeline.md) - Audit event ingestion -- [Event Pipeline](./event-pipeline.md) - Kubernetes event ingestion -- [Activity Pipeline](./activity-pipeline.md) - Activity generation +# Data Model + +The activity service stores data in a ClickHouse cluster managed by the +[Altinity ClickHouse Operator][ch-operator]. The cluster runs 3 replicas +coordinated by [ClickHouse Keeper][ch-keeper] for high availability. + +[ch-operator]: https://github.com/Altinity/clickhouse-operator +[ch-keeper]: https://clickhouse.com/docs/en/guides/sre/keeper/clickhouse-keeper + +## Storage Architecture + +All tables use `ReplicatedReplacingMergeTree` with: +- Daily or monthly partitioning for efficient TTL management +- Primary keys optimized for time-scoped tenant queries +- Bloom filter and minmax skip indexes for common filter patterns +- ZSTD compression for JSON columns + +### Retention Policies + +| Table | Retention | Storage | +|-------|-----------|---------| +| Audit Events | Unlimited | Hot (90 days) → Cold (S3) | +| Events | 60 days | Hot only | +| Activities | 60 days | Hot only | + +Audit logs use tiered storage: data automatically moves from local SSD to +S3-compatible cold storage after 90 days. A 10 GB local cache accelerates +queries against cold data. Events and activities are deleted after their +retention period expires. + +## Audit Events Table + +The `audit.events` table stores raw audit events from the control plane: + +```sql +CREATE TABLE audit.events ( + -- Full audit event as compressed JSON + event_json String CODEC(ZSTD(3)), + + -- Extracted timestamp for partitioning and ordering + timestamp DateTime64(6), + + -- Tenant scope + scope_type LowCardinality(String), -- Organization, Project, User + scope_name String, + + -- Actor identity + user String, + user_uid String, + + -- Request metadata + verb LowCardinality(String), + resource LowCardinality(String), + api_group LowCardinality(String), + namespace String, + resource_name String, + + -- Response + status_code UInt16 + +) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/audit_events', '{replica}') +PARTITION BY toYYYYMMDD(timestamp) +ORDER BY (scope_type, scope_name, timestamp, user_uid) +TTL timestamp + INTERVAL 90 DAY TO VOLUME 'cold' +SETTINGS index_granularity = 8192; +``` + +### Indexes + +| Index | Type | Columns | Purpose | +|-------|------|---------|---------| +| `idx_verb` | bloom_filter | `verb` | Filter by operation type | +| `idx_resource` | bloom_filter | `resource, resource_name` | Resource lookups | +| `idx_user` | bloom_filter | `user, user_uid` | Actor-based queries | +| `idx_status` | minmax | `status_code` | Error filtering | + +### Projections + +Three projections provide optimized sort orders: + +```sql +-- Platform-wide queries (sorted by time) +PROJECTION platform_queries ( + SELECT * ORDER BY timestamp, scope_type, scope_name +) + +-- Username-based queries +PROJECTION user_queries ( + SELECT * ORDER BY user, timestamp +) + +-- User UID-based queries (stable across name changes) +PROJECTION uid_queries ( + SELECT * ORDER BY user_uid, timestamp +) +``` + +## Events Table + +The `audit.k8s_events` table stores Kubernetes Events (core/v1.Event) for +multi-tenant environments. + +### Storage Model + +Events use an **insert-only model** where each event state (as `lastTimestamp` +changes) becomes a separate row. This allows `last_timestamp` to be in the +primary key for efficient ordering. Queries use `LIMIT 1 BY uid` to get the +latest state per event. `ReplacingMergeTree` handles true duplicates from +pipeline retries. + +```sql +CREATE TABLE audit.k8s_events ( + -- Full event as compressed JSON + event_json String CODEC(ZSTD(3)), + + -- Insertion timestamp for ResourceVersion (nanoseconds for monotonicity) + inserted_at DateTime64(9), + + -- Tenant scope (primary query dimension) + scope_type LowCardinality(String), -- Organization, Project + scope_name String, + + -- Timestamps + first_timestamp DateTime64(3), + last_timestamp DateTime64(3), + + -- Event metadata + namespace LowCardinality(String), + name String, + uid String, + + -- Regarding object (the resource the event is about) + regarding_api_group LowCardinality(String), -- e.g., "apps", "networking.k8s.io" + regarding_api_version LowCardinality(String), + regarding_kind LowCardinality(String), -- e.g., "Pod", "Deployment" + regarding_namespace LowCardinality(String), + regarding_name String, + regarding_uid String, + + -- Event classification + reason LowCardinality(String), -- e.g., "Scheduled", "Pulling", "Created" + type LowCardinality(String), -- "Normal" or "Warning" + + -- Source (what generated the event) + source_component LowCardinality(String), -- e.g., "kubelet", "deployment-controller" + source_host String + +) ENGINE = ReplicatedReplacingMergeTree(inserted_at) +PARTITION BY toYYYYMMDD(last_timestamp) +ORDER BY (scope_type, scope_name, last_timestamp, regarding_api_group, regarding_kind, type, uid) +TTL last_timestamp + INTERVAL 60 DAY DELETE; +``` + +### Example Query + +To get the latest state of each event, sorted by most recent activity: + +```sql +SELECT * FROM audit.k8s_events +WHERE scope_type = 'organization' AND scope_name = 'acme' +ORDER BY last_timestamp DESC +LIMIT 1 BY uid +``` + +### Query Patterns + +The table is optimized for four primary query patterns: + +1. **Multi-tenant queries** (default): Filter by scope, then time range +2. **API group/resource queries**: Find events for specific resource types +3. **Platform-wide queries**: Time-range queries across all tenants +4. **Source component queries**: Events from specific controllers + +### Indexes + +| Index | Type | Columns | Purpose | +|-------|------|---------|---------| +| `idx_scope_name_bloom` | bloom_filter | `scope_name` | Tenant filtering | +| `idx_regarding_api_group` | set | `regarding_api_group` | API group queries | +| `idx_regarding_kind_set` | set | `regarding_kind` | Resource type filtering | +| `idx_regarding_name_bloom` | bloom_filter | `regarding_name` | Resource name lookups | +| `idx_regarding_uid_bloom` | bloom_filter | `regarding_uid` | Resource UID lookups | +| `idx_reason_set` | set | `reason` | Event reason filtering | +| `idx_type_set` | set | `type` | Normal vs Warning | +| `idx_source_component` | set | `source_component` | Controller/component filtering | + +### Projections + +Three projections provide optimized sort orders: + +```sql +-- Platform-wide queries (sorted by time across all tenants) +PROJECTION platform_query_projection ( + SELECT * ORDER BY (last_timestamp, scope_type, scope_name, + regarding_api_group, regarding_kind, type, uid) +) + +-- API group/resource queries (sorted by regarding object type) +PROJECTION regarding_object_query_projection ( + SELECT * ORDER BY (regarding_api_group, regarding_kind, scope_type, + scope_name, last_timestamp, type, uid) +) + +-- Source component queries (by generating controller/component) +PROJECTION source_query_projection ( + SELECT * ORDER BY (source_component, last_timestamp, scope_type, + scope_name, regarding_api_group, regarding_kind, type, uid) +) +``` + +## Activities Table + +The `activity.activities` table stores translated activity records: + +```sql +CREATE TABLE activity.activities ( + -- Full activity as compressed JSON + activity_json String CODEC(ZSTD(3)), + + -- Extracted timestamp + timestamp DateTime64(6), + + -- Tenant scope + tenant_type LowCardinality(String), -- global, organization, project, user + tenant_name String, + + -- Origin tracking + origin_type LowCardinality(String), -- audit, event + origin_id String, + + -- Change classification + change_source LowCardinality(String), -- human, system + + -- Actor + actor_type LowCardinality(String), -- user, serviceaccount, controller + actor_name String, + actor_uid String, + + -- Affected resource + api_group LowCardinality(String), + resource_kind LowCardinality(String), + resource_name String, + resource_namespace String, + resource_uid String, + + -- Human-readable summary for full-text search + summary String + +) ENGINE = ReplicatedReplacingMergeTree('/clickhouse/tables/{shard}/activities', '{replica}') +PARTITION BY toYYYYMMDD(timestamp) +ORDER BY (tenant_type, tenant_name, timestamp, resource_uid) +TTL timestamp + INTERVAL 30 DAY +SETTINGS index_granularity = 8192; +``` + +### Indexes + +| Index | Type | Columns | Purpose | +|-------|------|---------|---------| +| `idx_api_group` | bloom_filter | `api_group` | Service provider queries | +| `idx_actor` | bloom_filter | `actor_name` | Actor-based filtering | +| `idx_resource` | bloom_filter | `resource_kind, resource_name` | Resource lookups | +| `idx_change_source` | minmax | `change_source` | Human vs system filtering | +| `idx_summary_search` | tokenbf_v1 | `summary` | Full-text search | + +### Full-Text Search + +The `idx_summary_search` index uses ClickHouse's `tokenbf_v1` bloom filter for +efficient token matching: + +```sql +INDEX idx_summary_search summary TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 4 +``` + +This enables queries like: + +```sql +SELECT * FROM activities +WHERE hasToken(summary, 'HTTPProxy') + AND hasToken(summary, 'created') +``` + +## Write Consistency + +All tables are configured for strong consistency: + +- Writes require acknowledgment from 2 of 3 replicas before returning success +- Reads use sequential consistency for read-after-write guarantees +- 7-day deduplication windows prevent duplicate records from pipeline retries + +## Related Documentation + +- [Architecture Overview](./README.md) +- [Audit Pipeline](./audit-pipeline.md) - Audit event ingestion +- [Event Pipeline](./event-pipeline.md) - Kubernetes event ingestion +- [Activity Pipeline](./activity-pipeline.md) - Activity generation diff --git a/docs/cli-user-guide.md b/docs/cli-user-guide.md index c9dd83ce..7f90dcde 100644 --- a/docs/cli-user-guide.md +++ b/docs/cli-user-guide.md @@ -1,330 +1,330 @@ -# Activity CLI User Guide - -The Activity CLI makes it easy to query and analyze your Kubernetes cluster's -audit logs. Instead of digging through log files or complex log aggregation -systems, you can use simple commands to answer questions like "who deleted that -secret?" or "what changed in production last week?" - -## What is the Activity CLI? - -The Activity CLI is a command-line tool that lets you search through Kubernetes -audit logs using familiar kubectl-style commands. It connects to the Activity -API server, which indexes audit logs in a fast ClickHouse database, giving you -instant answers to questions about cluster activity. - -Think of it as a search engine for everything that happens in your -cluster—deployments, secrets, configuration changes, deletions, and more. - -The CLI is designed to be flexible and can be used in two ways: -1. **As a standalone kubectl plugin** (`kubectl-activity`) - Use it directly - with `kubectl activity` commands -2. **Embedded in your own CLI** - The Activity CLI is built as a reusable Go - library that can be integrated into custom command-line tools, allowing you - to add audit log querying capabilities to your own applications - -## Installation - -### As a kubectl Plugin - -The Activity CLI works as a kubectl plugin. Once installed, you can use it with: - -```bash -kubectl activity -``` - -Or directly as: - -```bash -kubectl-activity -``` - -### Embedding in Your CLI - -If you're building your own CLI tool, you can embed the Activity commands using -the `NewActivityCommand()` function. This allows you to provide audit log -querying capabilities within your own application: - -```go -import ( - "github.com/spf13/cobra" - activitycmd "go.miloapis.com/activity/pkg/cmd" -) - -// Add activity command to your root command -rootCmd.AddCommand(activitycmd.NewActivityCommand(activitycmd.ActivityCommandOptions{})) -``` - -You can customize the behavior by providing your own factory, IO streams, or -config flags through `ActivityCommandOptions`: - -```go -// Custom configuration -opts := activitycmd.ActivityCommandOptions{ - Factory: myKubectlFactory, - IOStreams: myIOStreams, - ConfigFlags: myConfigFlags, -} -rootCmd.AddCommand(activitycmd.NewActivityCommand(opts)) -``` - -## Commands - -The Activity CLI provides two main commands: - -### 1. `query` - Search audit logs - -Use `query` to search audit logs across your cluster using time ranges and -filters. - -**Basic usage:** -```bash -# View recent activity (last 24 hours by default) -kubectl activity query - -# Search the last hour -kubectl activity query --start-time "now-1h" - -# Search a specific time range -kubectl activity query --start-time "now-7d" --end-time "now" -``` - -**Filtering results:** - -Use CEL (Common Expression Language) filters to narrow down results: - -```bash -# Find all deletions -kubectl activity query --filter "verb == 'delete'" - -# Find deletions in a specific namespace -kubectl activity query --filter "verb == 'delete' && objectRef.namespace == 'production'" - -# Find secret access -kubectl activity query --filter "objectRef.resource == 'secrets'" - -# Find failed operations -kubectl activity query --filter "responseStatus.code >= 400" - -# Find service account activity -kubectl activity query --filter "user.username.startsWith('system:serviceaccount:')" - -# Combine multiple conditions -kubectl activity query --filter "verb in ['create', 'update', 'delete', 'patch'] && objectRef.namespace == 'production'" -``` - -**Output formats:** - -The query command supports standard kubectl output formats: - -```bash -# Table format (default) -kubectl activity query - -# JSON output -kubectl activity query -o json - -# YAML output -kubectl activity query -o yaml - -# Custom output with JSONPath -kubectl activity query -o jsonpath='{.items[*].verb}' - -# Custom output with Go templates -kubectl activity query -o go-template='{{range .items}}{{.verb}} {{.user.username}}{{"\n"}}{{end}}' -``` - -**Pagination:** - -```bash -# Limit results per page (default: 25) -kubectl activity query --limit 100 - -# Get the next page using a continuation token -kubectl activity query --continue-after "eyJhbGciOiJ..." - -# Fetch all results automatically across all pages -kubectl activity query --all-pages -``` - -### 2. `history` - View resource change history - -Use `history` to see how a specific resource has changed over time. - -**Basic usage:** -```bash -# View history of a domain resource -kubectl activity history domains example-com -n production - -# View history with diff to see what changed -kubectl activity history configmaps app-config -n default --diff - -# View changes from the last 7 days -kubectl activity history secrets api-credentials -n default --start-time "now-7d" -``` - -**Examples:** - -```bash -# View all changes to a specific DNS record -kubectl activity history dnsrecordsets dns-record-www-example-com -n production - -# See detailed diffs between versions -kubectl activity history configmaps app-settings -n default --diff - -# View in JSON format -kubectl activity history domains example-com -n default -o json - -# Get complete history across all pages -kubectl activity history secrets db-password -n default --all-pages -``` - -**Output modes:** - -- **Table (default)**: Shows a table with timestamp, verb, user, and status code -- **`--diff`**: Shows unified diff between consecutive resource versions with - color-coded changes -- **`-o json/yaml`**: Output raw audit events in structured format - -## Time Formats - -The Activity CLI supports two types of time formats: - -**Relative time:** -- `now-30m` - 30 minutes ago -- `now-2h` - 2 hours ago -- `now-7d` - 7 days ago -- `now-1w` - 1 week ago -- Units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks) - -**Absolute time:** -- `2024-01-01T00:00:00Z` - RFC3339 format with timezone -- `2024-12-25T12:00:00-05:00` - With timezone offset - -## Common Use Cases - -### Incident Investigation - -```bash -# Find who deleted a resource in the last hour -kubectl activity query --start-time "now-1h" \ - --filter "verb == 'delete' && objectRef.name == 'my-service'" - -# Track down failed operations -kubectl activity query --filter "responseStatus.code >= 400" -``` - -### Security Auditing - -```bash -# Find all secret access in the last 24 hours -kubectl activity query --filter "objectRef.resource == 'secrets'" - -# Track privilege escalation attempts -kubectl activity query --filter "verb == 'create' && objectRef.resource == 'rolebindings'" - -# Monitor service account activity -kubectl activity query --filter "user.username.startsWith('system:serviceaccount:')" -``` - -### Compliance Reporting - -```bash -# Generate a report of all changes in production -kubectl activity query --start-time "now-30d" \ - --filter "objectRef.namespace == 'production' && verb in ['create', 'update', 'delete', 'patch']" \ - --all-pages -o json > production-changes.json - -# Track configuration changes -kubectl activity query --filter "objectRef.resource in ['configmaps', 'secrets']" \ - --all-pages -``` - -### Change Tracking - -```bash -# See complete history of a critical resource -kubectl activity history secrets database-credentials -n production --diff - -# Track domain configuration changes -kubectl activity history domains api-example-com -n default --all-pages -``` - -## Global Flags - -The Activity CLI inherits standard kubectl flags for cluster connectivity: - -```bash ---kubeconfig string Path to the kubeconfig file ---context string The name of the kubeconfig context to use ---namespace string, -n Namespace scope -``` - -## Filter Reference - -Common filter expressions using CEL: - -| Filter | Description | -|--------|-------------| -| `verb == 'delete'` | All deletions | -| `verb in ['create', 'update', 'delete', 'patch']` | Write operations | -| `objectRef.namespace == 'production'` | Events in production namespace | -| `objectRef.resource == 'secrets'` | Secret access | -| `objectRef.name == 'my-app'` | Specific resource name | -| `user.username == 'alice@example.com'` | Actions by specific user | -| `user.username.startsWith('system:serviceaccount:')` | Service account activity | -| `responseStatus.code >= 400` | Failed requests | -| `responseStatus.code == 200` | Successful requests | - -You can combine filters with `&&` (AND) and `\|\|` (OR): - -```bash -kubectl activity query --filter "verb == 'delete' && objectRef.namespace == 'production'" -``` - -## Tips and Best Practices - -1. **Start broad, then filter**: Begin with a wide time range and basic filters, - then narrow down based on what you find. - -2. **Use `--diff` for investigations**: When tracking down what changed in a - resource, the `--diff` flag shows you exactly what was modified. - -3. **Save complex queries**: Create shell aliases or scripts for frequently-used - queries: - ```bash - alias prod-deletions='kubectl activity query --filter "verb == '\''delete'\'' && objectRef.namespace == '\''production'\''"' - ``` - -4. **Use `--all-pages` carefully**: This fetches all results, which can be a lot - of data for broad queries. Start with a limited query to see how many results - you're dealing with. - -5. **Leverage output formats**: Use `-o json` or `-o yaml` with tools like `jq` - for post-processing: - ```bash - kubectl activity query -o json | jq '.items[] | select(.verb=="delete") | .objectRef.name' - ``` - -6. **Optimize time ranges**: Narrower time ranges return results faster. If you - know approximately when something happened, use that to your advantage. - -## Troubleshooting - -**"Query failed: connection refused"** -- Ensure the Activity API server is running and accessible -- Check your kubeconfig and context settings - -**"Query failed: unauthorized"** -- Verify you have the necessary RBAC permissions to query audit logs -- Contact your cluster administrator for access - -**No results returned** -- Double-check your time range (default is last 24 hours) -- Verify your filter syntax using simple filters first -- Ensure the resource type name is correct (use plural form: `secrets`, not - `secret`) - -## Learn More - -- For API details, see [docs/api.md](./api.md) -- For development information, see the main [README.md](../README.md) +# Activity CLI User Guide + +The Activity CLI makes it easy to query and analyze your Kubernetes cluster's +audit logs. Instead of digging through log files or complex log aggregation +systems, you can use simple commands to answer questions like "who deleted that +secret?" or "what changed in production last week?" + +## What is the Activity CLI? + +The Activity CLI is a command-line tool that lets you search through Kubernetes +audit logs using familiar kubectl-style commands. It connects to the Activity +API server, which indexes audit logs in a fast ClickHouse database, giving you +instant answers to questions about cluster activity. + +Think of it as a search engine for everything that happens in your +cluster—deployments, secrets, configuration changes, deletions, and more. + +The CLI is designed to be flexible and can be used in two ways: +1. **As a standalone kubectl plugin** (`kubectl-activity`) - Use it directly + with `kubectl activity` commands +2. **Embedded in your own CLI** - The Activity CLI is built as a reusable Go + library that can be integrated into custom command-line tools, allowing you + to add audit log querying capabilities to your own applications + +## Installation + +### As a kubectl Plugin + +The Activity CLI works as a kubectl plugin. Once installed, you can use it with: + +```bash +kubectl activity +``` + +Or directly as: + +```bash +kubectl-activity +``` + +### Embedding in Your CLI + +If you're building your own CLI tool, you can embed the Activity commands using +the `NewActivityCommand()` function. This allows you to provide audit log +querying capabilities within your own application: + +```go +import ( + "github.com/spf13/cobra" + activitycmd "go.miloapis.com/activity/pkg/cmd" +) + +// Add activity command to your root command +rootCmd.AddCommand(activitycmd.NewActivityCommand(activitycmd.ActivityCommandOptions{})) +``` + +You can customize the behavior by providing your own factory, IO streams, or +config flags through `ActivityCommandOptions`: + +```go +// Custom configuration +opts := activitycmd.ActivityCommandOptions{ + Factory: myKubectlFactory, + IOStreams: myIOStreams, + ConfigFlags: myConfigFlags, +} +rootCmd.AddCommand(activitycmd.NewActivityCommand(opts)) +``` + +## Commands + +The Activity CLI provides two main commands: + +### 1. `query` - Search audit logs + +Use `query` to search audit logs across your cluster using time ranges and +filters. + +**Basic usage:** +```bash +# View recent activity (last 24 hours by default) +kubectl activity query + +# Search the last hour +kubectl activity query --start-time "now-1h" + +# Search a specific time range +kubectl activity query --start-time "now-7d" --end-time "now" +``` + +**Filtering results:** + +Use CEL (Common Expression Language) filters to narrow down results: + +```bash +# Find all deletions +kubectl activity query --filter "verb == 'delete'" + +# Find deletions in a specific namespace +kubectl activity query --filter "verb == 'delete' && objectRef.namespace == 'production'" + +# Find secret access +kubectl activity query --filter "objectRef.resource == 'secrets'" + +# Find failed operations +kubectl activity query --filter "responseStatus.code >= 400" + +# Find service account activity +kubectl activity query --filter "user.username.startsWith('system:serviceaccount:')" + +# Combine multiple conditions +kubectl activity query --filter "verb in ['create', 'update', 'delete', 'patch'] && objectRef.namespace == 'production'" +``` + +**Output formats:** + +The query command supports standard kubectl output formats: + +```bash +# Table format (default) +kubectl activity query + +# JSON output +kubectl activity query -o json + +# YAML output +kubectl activity query -o yaml + +# Custom output with JSONPath +kubectl activity query -o jsonpath='{.items[*].verb}' + +# Custom output with Go templates +kubectl activity query -o go-template='{{range .items}}{{.verb}} {{.user.username}}{{"\n"}}{{end}}' +``` + +**Pagination:** + +```bash +# Limit results per page (default: 25) +kubectl activity query --limit 100 + +# Get the next page using a continuation token +kubectl activity query --continue-after "eyJhbGciOiJ..." + +# Fetch all results automatically across all pages +kubectl activity query --all-pages +``` + +### 2. `history` - View resource change history + +Use `history` to see how a specific resource has changed over time. + +**Basic usage:** +```bash +# View history of a domain resource +kubectl activity history domains example-com -n production + +# View history with diff to see what changed +kubectl activity history configmaps app-config -n default --diff + +# View changes from the last 7 days +kubectl activity history secrets api-credentials -n default --start-time "now-7d" +``` + +**Examples:** + +```bash +# View all changes to a specific DNS record +kubectl activity history dnsrecordsets dns-record-www-example-com -n production + +# See detailed diffs between versions +kubectl activity history configmaps app-settings -n default --diff + +# View in JSON format +kubectl activity history domains example-com -n default -o json + +# Get complete history across all pages +kubectl activity history secrets db-password -n default --all-pages +``` + +**Output modes:** + +- **Table (default)**: Shows a table with timestamp, verb, user, and status code +- **`--diff`**: Shows unified diff between consecutive resource versions with + color-coded changes +- **`-o json/yaml`**: Output raw audit events in structured format + +## Time Formats + +The Activity CLI supports two types of time formats: + +**Relative time:** +- `now-30m` - 30 minutes ago +- `now-2h` - 2 hours ago +- `now-7d` - 7 days ago +- `now-1w` - 1 week ago +- Units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks) + +**Absolute time:** +- `2024-01-01T00:00:00Z` - RFC3339 format with timezone +- `2024-12-25T12:00:00-05:00` - With timezone offset + +## Common Use Cases + +### Incident Investigation + +```bash +# Find who deleted a resource in the last hour +kubectl activity query --start-time "now-1h" \ + --filter "verb == 'delete' && objectRef.name == 'my-service'" + +# Track down failed operations +kubectl activity query --filter "responseStatus.code >= 400" +``` + +### Security Auditing + +```bash +# Find all secret access in the last 24 hours +kubectl activity query --filter "objectRef.resource == 'secrets'" + +# Track privilege escalation attempts +kubectl activity query --filter "verb == 'create' && objectRef.resource == 'rolebindings'" + +# Monitor service account activity +kubectl activity query --filter "user.username.startsWith('system:serviceaccount:')" +``` + +### Compliance Reporting + +```bash +# Generate a report of all changes in production +kubectl activity query --start-time "now-30d" \ + --filter "objectRef.namespace == 'production' && verb in ['create', 'update', 'delete', 'patch']" \ + --all-pages -o json > production-changes.json + +# Track configuration changes +kubectl activity query --filter "objectRef.resource in ['configmaps', 'secrets']" \ + --all-pages +``` + +### Change Tracking + +```bash +# See complete history of a critical resource +kubectl activity history secrets database-credentials -n production --diff + +# Track domain configuration changes +kubectl activity history domains api-example-com -n default --all-pages +``` + +## Global Flags + +The Activity CLI inherits standard kubectl flags for cluster connectivity: + +```bash +--kubeconfig string Path to the kubeconfig file +--context string The name of the kubeconfig context to use +--namespace string, -n Namespace scope +``` + +## Filter Reference + +Common filter expressions using CEL: + +| Filter | Description | +|--------|-------------| +| `verb == 'delete'` | All deletions | +| `verb in ['create', 'update', 'delete', 'patch']` | Write operations | +| `objectRef.namespace == 'production'` | Events in production namespace | +| `objectRef.resource == 'secrets'` | Secret access | +| `objectRef.name == 'my-app'` | Specific resource name | +| `user.username == 'alice@example.com'` | Actions by specific user | +| `user.username.startsWith('system:serviceaccount:')` | Service account activity | +| `responseStatus.code >= 400` | Failed requests | +| `responseStatus.code == 200` | Successful requests | + +You can combine filters with `&&` (AND) and `\|\|` (OR): + +```bash +kubectl activity query --filter "verb == 'delete' && objectRef.namespace == 'production'" +``` + +## Tips and Best Practices + +1. **Start broad, then filter**: Begin with a wide time range and basic filters, + then narrow down based on what you find. + +2. **Use `--diff` for investigations**: When tracking down what changed in a + resource, the `--diff` flag shows you exactly what was modified. + +3. **Save complex queries**: Create shell aliases or scripts for frequently-used + queries: + ```bash + alias prod-deletions='kubectl activity query --filter "verb == '\''delete'\'' && objectRef.namespace == '\''production'\''"' + ``` + +4. **Use `--all-pages` carefully**: This fetches all results, which can be a lot + of data for broad queries. Start with a limited query to see how many results + you're dealing with. + +5. **Leverage output formats**: Use `-o json` or `-o yaml` with tools like `jq` + for post-processing: + ```bash + kubectl activity query -o json | jq '.items[] | select(.verb=="delete") | .objectRef.name' + ``` + +6. **Optimize time ranges**: Narrower time ranges return results faster. If you + know approximately when something happened, use that to your advantage. + +## Troubleshooting + +**"Query failed: connection refused"** +- Ensure the Activity API server is running and accessible +- Check your kubeconfig and context settings + +**"Query failed: unauthorized"** +- Verify you have the necessary RBAC permissions to query audit logs +- Contact your cluster administrator for access + +**No results returned** +- Double-check your time range (default is last 24 hours) +- Verify your filter syntax using simple filters first +- Ensure the resource type name is correct (use plural form: `secrets`, not + `secret`) + +## Learn More + +- For API details, see [docs/api.md](./api.md) +- For development information, see the main [README.md](../README.md) diff --git a/go.mod b/go.mod index 87e0a513..cd60d1d7 100644 --- a/go.mod +++ b/go.mod @@ -1,162 +1,162 @@ -module go.miloapis.com/activity - -go 1.25.0 - -require ( - github.com/ClickHouse/clickhouse-go/v2 v2.42.0 - github.com/google/cel-go v0.26.0 - github.com/google/uuid v1.6.0 - github.com/modelcontextprotocol/go-sdk v0.3.0 - github.com/nats-io/nats.go v1.48.0 - github.com/pmezard/go-difflib v1.0.0 - github.com/prometheus/client_golang v1.23.2 - github.com/spf13/cobra v1.10.2 - github.com/spf13/pflag v1.0.10 - github.com/stretchr/testify v1.11.1 - go.opentelemetry.io/otel v1.39.0 - go.opentelemetry.io/otel/trace v1.39.0 - golang.org/x/term v0.38.0 - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 - k8s.io/api v0.35.0 - k8s.io/apiextensions-apiserver v0.35.0 - k8s.io/apimachinery v0.35.0 - k8s.io/apiserver v0.35.0 - k8s.io/cli-runtime v0.34.3 - k8s.io/client-go v0.35.0 - k8s.io/component-base v0.35.0 - k8s.io/klog/v2 v2.130.1 - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 - k8s.io/kubectl v0.34.3 - sigs.k8s.io/controller-runtime v0.23.1 - sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 -) - -require ( - cel.dev/expr v0.24.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect - github.com/ClickHouse/ch-go v0.69.0 // indirect - github.com/MakeNowJust/heredoc v1.0.0 // indirect - github.com/NYTimes/gziphandler v1.1.1 // indirect - github.com/andybalholm/brotli v1.2.0 // indirect - github.com/antlr4-go/antlr/v4 v4.13.0 // indirect - github.com/beorn7/perks v1.0.1 // indirect - github.com/blang/semver/v4 v4.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/chai2010/gettext-go v1.0.2 // indirect - github.com/coreos/go-semver v0.3.1 // indirect - github.com/coreos/go-systemd/v22 v22.5.0 // indirect - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/emicklei/go-restful/v3 v3.12.2 // indirect - github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-errors/errors v1.4.2 // indirect - github.com/go-faster/city v1.0.1 // indirect - github.com/go-faster/errors v0.7.1 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.1 // indirect - github.com/go-openapi/jsonreference v0.21.3 // indirect - github.com/go-openapi/swag v0.23.0 // indirect - github.com/go-openapi/swag/jsonname v0.25.1 // indirect - github.com/gobuffalo/flect v1.0.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/protobuf v1.5.4 // indirect - github.com/google/btree v1.1.3 // indirect - github.com/google/gnostic-models v0.7.0 // indirect - github.com/google/go-cmp v0.7.0 // indirect - github.com/google/jsonschema-go v0.2.0 // indirect - github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect - github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect - github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect - github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mitchellh/go-wordwrap v1.0.1 // indirect - github.com/moby/spdystream v0.5.0 // indirect - github.com/moby/term v0.5.0 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect - github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect - github.com/nats-io/nkeys v0.4.11 // indirect - github.com/nats-io/nuid v1.0.1 // indirect - github.com/onsi/gomega v1.38.3 // indirect - github.com/paulmach/orb v0.12.0 // indirect - github.com/peterbourgon/diskv v2.0.1+incompatible // indirect - github.com/pierrec/lz4/v4 v4.1.23 // indirect - github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect - github.com/russross/blackfriday/v2 v2.1.0 // indirect - github.com/segmentio/asm v1.2.1 // indirect - github.com/shopspring/decimal v1.4.0 // indirect - github.com/stoewer/go-strcase v1.3.0 // indirect - github.com/x448/float16 v0.8.4 // indirect - github.com/xlab/treeprint v1.2.0 // indirect - github.com/yosida95/uritemplate/v3 v3.0.2 // indirect - go.etcd.io/etcd/api/v3 v3.6.5 // indirect - go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect - go.etcd.io/etcd/client/v3 v3.6.5 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/sdk v1.39.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.46.0 // indirect - golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/mod v0.31.0 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.9.0 // indirect - golang.org/x/tools v0.40.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.77.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/code-generator v0.35.0 // indirect - k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect - k8s.io/kms v0.35.0 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect - sigs.k8s.io/controller-tools v0.20.1 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/kustomize/api v0.20.1 // indirect - sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect -) - -tool ( - k8s.io/code-generator - k8s.io/kube-openapi/cmd/openapi-gen - sigs.k8s.io/controller-tools/cmd/controller-gen -) +module go.miloapis.com/activity + +go 1.25.0 + +require ( + github.com/ClickHouse/clickhouse-go/v2 v2.42.0 + github.com/google/cel-go v0.26.0 + github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v0.3.0 + github.com/nats-io/nats.go v1.48.0 + github.com/pmezard/go-difflib v1.0.0 + github.com/prometheus/client_golang v1.23.2 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/otel v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + golang.org/x/term v0.38.0 + google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 + k8s.io/api v0.35.0 + k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/apiserver v0.35.0 + k8s.io/cli-runtime v0.34.3 + k8s.io/client-go v0.35.0 + k8s.io/component-base v0.35.0 + k8s.io/klog/v2 v2.130.1 + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 + k8s.io/kubectl v0.34.3 + sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 +) + +require ( + cel.dev/expr v0.24.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/ClickHouse/ch-go v0.69.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/NYTimes/gziphandler v1.1.1 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.2.0 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/nats-io/nkeys v0.4.11 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/onsi/gomega v1.38.3 // indirect + github.com/paulmach/orb v0.12.0 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/pierrec/lz4/v4 v4.1.23 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.etcd.io/etcd/api/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.6.5 // indirect + go.etcd.io/etcd/client/v3 v3.6.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.46.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/time v0.9.0 // indirect + golang.org/x/tools v0.40.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect + google.golang.org/grpc v1.77.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/code-generator v0.35.0 // indirect + k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b // indirect + k8s.io/kms v0.35.0 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect + sigs.k8s.io/controller-tools v0.20.1 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +tool ( + k8s.io/code-generator + k8s.io/kube-openapi/cmd/openapi-gen + sigs.k8s.io/controller-tools/cmd/controller-gen +) diff --git a/go.sum b/go.sum index 179c5bb9..1d039efb 100644 --- a/go.sum +++ b/go.sum @@ -1,445 +1,445 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= -github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= -github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM= -github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg= -github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk= -github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s= -github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= -github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= -github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= -github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= -github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= -github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= -github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= -github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= -github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= -github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= -github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= -github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= -github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= -github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= -github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= -github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= -github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= -github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= -github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= -github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= -github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= -github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= -github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= -github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= -github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= -github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= -github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= -github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= -github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= -github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= -github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= -github.com/modelcontextprotocol/go-sdk v0.3.0 h1:/1XC6+PpdKfE4CuFJz8/goo0An31bu8n8G8d3BkeJoY= -github.com/modelcontextprotocol/go-sdk v0.3.0/go.mod h1:71VUZVa8LL6WARvSgLJ7DMpDWSeomT4uBv8g97mGBvo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= -github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= -github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= -github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= -github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= -github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= -github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= -github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= -github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= -github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= -github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= -github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= -github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU= -github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= -github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= -github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= -github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= -github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= -github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= -github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= -github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= -github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= -github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= -github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= -github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= -github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= -github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= -github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= -go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= -go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= -go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= -go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= -go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= -go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= -go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= -go.etcd.io/etcd/pkg/v3 v3.6.5 h1:byxWB4AqIKI4SBmquZUG1WGtvMfMaorXFoCcFbVeoxM= -go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= -go.etcd.io/etcd/server/v3 v3.6.5 h1:4RbUb1Bd4y1WkBHmuF+cZII83JNQMuNXzyjwigQ06y0= -go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= -go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= -go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= -go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= -golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= -golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= -google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= -gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= -gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= -k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= -k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/cli-runtime v0.34.3 h1:YRyMhiwX0dT9lmG0AtZDaeG33Nkxgt9OlCTZhRXj9SI= -k8s.io/cli-runtime v0.34.3/go.mod h1:GVwL1L5uaGEgM7eGeKjaTG2j3u134JgG4dAI6jQKhMc= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= -k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= -k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= -k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= -k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= -k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= -k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/kubectl v0.34.3 h1:vpM6//153gh5gvsYHXWHVJ4l4xmN5QFwTSmlfd8icm8= -k8s.io/kubectl v0.34.3/go.mod h1:zZQHtIZoUqTP1bAnPzq/3W1jfc0NeOeunFgcswrfg1c= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= -sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= -sigs.k8s.io/controller-tools v0.20.1 h1:gkfMt9YodI0K85oT8rVi80NTXO/kDmabKR5Ajn5GYxs= -sigs.k8s.io/controller-tools v0.20.1/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= -sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= -sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= -sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= -sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM= +github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg= +github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk= +github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.2.0 h1:Uh19091iHC56//WOsAd1oRg6yy1P9BpSvpjOL6RcjLQ= +github.com/google/jsonschema-go v0.2.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0 h1:FbSCl+KggFl+Ocym490i/EyXF4lPgLoUtcSWquBM0Rs= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= +github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modelcontextprotocol/go-sdk v0.3.0 h1:/1XC6+PpdKfE4CuFJz8/goo0An31bu8n8G8d3BkeJoY= +github.com/modelcontextprotocol/go-sdk v0.3.0/go.mod h1:71VUZVa8LL6WARvSgLJ7DMpDWSeomT4uBv8g97mGBvo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U= +github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= +github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0= +github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= +github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU= +github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510 h1:S2dVYn90KE98chqDkyE9Z4N61UnQd+KOfgp5Iu53llk= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.5 h1:pMMc42276sgR1j1raO/Qv3QI9Af/AuyQUW6CBAWuntA= +go.etcd.io/etcd/api/v3 v3.6.5/go.mod h1:ob0/oWA/UQQlT1BmaEkWQzI0sJ1M0Et0mMpaABxguOQ= +go.etcd.io/etcd/client/pkg/v3 v3.6.5 h1:Duz9fAzIZFhYWgRjp/FgNq2gO1jId9Yae/rLn3RrBP8= +go.etcd.io/etcd/client/pkg/v3 v3.6.5/go.mod h1:8Wx3eGRPiy0qOFMZT/hfvdos+DjEaPxdIDiCDUv/FQk= +go.etcd.io/etcd/client/v3 v3.6.5 h1:yRwZNFBx/35VKHTcLDeO7XVLbCBFbPi+XV4OC3QJf2U= +go.etcd.io/etcd/client/v3 v3.6.5/go.mod h1:ZqwG/7TAFZ0BJ0jXRPoJjKQJtbFo/9NIY8uoFFKcCyo= +go.etcd.io/etcd/pkg/v3 v3.6.5 h1:byxWB4AqIKI4SBmquZUG1WGtvMfMaorXFoCcFbVeoxM= +go.etcd.io/etcd/pkg/v3 v3.6.5/go.mod h1:uqrXrzmMIJDEy5j00bCqhVLzR5jEJIwDp5wTlLwPGOU= +go.etcd.io/etcd/server/v3 v3.6.5 h1:4RbUb1Bd4y1WkBHmuF+cZII83JNQMuNXzyjwigQ06y0= +go.etcd.io/etcd/server/v3 v3.6.5/go.mod h1:PLuhyVXz8WWRhzXDsl3A3zv/+aK9e4A9lpQkqawIaH0= +go.etcd.io/raft/v3 v3.6.0 h1:5NtvbDVYpnfZWcIHgGRk9DyzkBIXOi8j+DDp1IcnUWQ= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= +google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/cli-runtime v0.34.3 h1:YRyMhiwX0dT9lmG0AtZDaeG33Nkxgt9OlCTZhRXj9SI= +k8s.io/cli-runtime v0.34.3/go.mod h1:GVwL1L5uaGEgM7eGeKjaTG2j3u134JgG4dAI6jQKhMc= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= +k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b h1:gMplByicHV/TJBizHd9aVEsTYoJBnnUAT5MHlTkbjhQ= +k8s.io/gengo/v2 v2.0.0-20250922181213-ec3ebc5fd46b/go.mod h1:CgujABENc3KuTrcsdpGmrrASjtQsWCT7R99mEV4U/fM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= +k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.34.3 h1:vpM6//153gh5gvsYHXWHVJ4l4xmN5QFwTSmlfd8icm8= +k8s.io/kubectl v0.34.3/go.mod h1:zZQHtIZoUqTP1bAnPzq/3W1jfc0NeOeunFgcswrfg1c= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-tools v0.20.1 h1:gkfMt9YodI0K85oT8rVi80NTXO/kDmabKR5Ajn5GYxs= +sigs.k8s.io/controller-tools v0.20.1/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/activityprocessor/dlq_retry.go b/internal/activityprocessor/dlq_retry.go index 24153fd8..72f38cbe 100644 --- a/internal/activityprocessor/dlq_retry.go +++ b/internal/activityprocessor/dlq_retry.go @@ -1,525 +1,525 @@ -package activityprocessor - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "math" - "sync" - "time" - - "github.com/nats-io/nats.go" - "github.com/prometheus/client_golang/prometheus" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/metrics" - - "go.miloapis.com/activity/internal/processor" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -var ( - dlqRetryAttemptsTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "dlq_retry", - Name: "attempts_total", - Help: "Total number of DLQ retry attempts", - }, - []string{"trigger", "api_group", "kind", "result"}, - ) - - dlqRetryBatchDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Namespace: "activity_processor", - Subsystem: "dlq_retry", - Name: "batch_duration_seconds", - Help: "Duration of DLQ retry batch processing", - Buckets: prometheus.DefBuckets, - }, - []string{"trigger"}, - ) - - dlqEventsHighRetryTotal = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "dlq_retry", - Name: "events_high_retry_total", - Help: "Total number of DLQ events that reached high retry threshold", - }, - []string{"api_group", "kind", "policy_name"}, - ) -) - -func init() { - metrics.Registry.MustRegister( - dlqRetryAttemptsTotal, - dlqRetryBatchDuration, - dlqEventsHighRetryTotal, - ) -} - -// DLQRetryConfig holds configuration for the DLQ retry controller. -type DLQRetryConfig struct { - // Enabled controls whether automatic retry is enabled. - Enabled bool - // Interval is how often to check for retry-eligible events. - Interval time.Duration - // BatchSize is how many events to process per batch. - BatchSize int - // BackoffBase is the initial backoff duration. - BackoffBase time.Duration - // BackoffMultiplier is the exponential multiplier (typically 2.0). - BackoffMultiplier float64 - // BackoffMax is the maximum backoff duration. - BackoffMax time.Duration - // AlertThreshold triggers metrics when retry count exceeds this. - AlertThreshold int - // AuditRetrySubject is the subject to republish audit events to. - AuditRetrySubject string - // EventRetrySubject is the subject to republish Kubernetes events to. - EventRetrySubject string -} - -// DefaultDLQRetryConfig returns sensible defaults for DLQ retry. -func DefaultDLQRetryConfig() DLQRetryConfig { - return DLQRetryConfig{ - Enabled: true, - Interval: 5 * time.Minute, - BatchSize: 100, - BackoffBase: 1 * time.Minute, - BackoffMultiplier: 2.0, - BackoffMax: 24 * time.Hour, - AlertThreshold: 10, - AuditRetrySubject: "audit.k8s.retry", - EventRetrySubject: "events.retry", - } -} - -// DLQRetryController manages automatic retry of dead-letter queue events. -type DLQRetryController struct { - js nats.JetStreamContext - config DLQRetryConfig - - auditStreamName string - eventStreamName string - dlqStreamName string - dlqSubjectPrefix string - - // mu protects concurrent access during policy-triggered retries - mu sync.Mutex - - // activeRetries tracks which policies have active retry operations - // to prevent concurrent retries for the same policy - activeRetries map[string]bool - activeRetriesMu sync.Mutex -} - -// NewDLQRetryController creates a new DLQ retry controller. -func NewDLQRetryController( - js nats.JetStreamContext, - config DLQRetryConfig, - auditStreamName string, - eventStreamName string, - dlqStreamName string, - dlqSubjectPrefix string, -) *DLQRetryController { - return &DLQRetryController{ - js: js, - config: config, - auditStreamName: auditStreamName, - eventStreamName: eventStreamName, - dlqStreamName: dlqStreamName, - dlqSubjectPrefix: dlqSubjectPrefix, - activeRetries: make(map[string]bool), - } -} - -// Start begins the retry controller with periodic retry. -func (c *DLQRetryController) Start(ctx context.Context) error { - if !c.config.Enabled { - klog.Info("DLQ retry controller is disabled") - return nil - } - - klog.InfoS("Starting DLQ retry controller", - "interval", c.config.Interval, - "batchSize", c.config.BatchSize, - "backoffBase", c.config.BackoffBase, - "backoffMax", c.config.BackoffMax, - ) - - ticker := time.NewTicker(c.config.Interval) - defer ticker.Stop() - - // Initial run - c.periodicRetry(ctx) - - for { - select { - case <-ctx.Done(): - klog.Info("DLQ retry controller stopping") - return nil - case <-ticker.C: - c.periodicRetry(ctx) - } - } -} - -// periodicRetry processes a batch of retry-eligible DLQ events. -func (c *DLQRetryController) periodicRetry(ctx context.Context) { - c.mu.Lock() - defer c.mu.Unlock() - - start := time.Now() - processed, succeeded, failed := c.processRetryBatch(ctx, "periodic", nil) - dlqRetryBatchDuration.WithLabelValues("periodic").Observe(time.Since(start).Seconds()) - - if processed > 0 { - klog.InfoS("Completed periodic DLQ retry batch", - "processed", processed, - "succeeded", succeeded, - "failed", failed, - "duration", time.Since(start), - ) - } -} - -// RetryForPolicy triggers immediate retry for events that match a specific policy. -// This is called when an ActivityPolicy is updated. -func (c *DLQRetryController) RetryForPolicy(ctx context.Context, policy *v1alpha1.ActivityPolicy) { - if !c.config.Enabled || policy == nil { - return - } - - // Check if retry is already in progress for this policy - c.activeRetriesMu.Lock() - if c.activeRetries[policy.Name] { - c.activeRetriesMu.Unlock() - klog.V(2).InfoS("Retry already in progress for policy, skipping", - "policy", policy.Name) - return - } - c.activeRetries[policy.Name] = true - c.activeRetriesMu.Unlock() - - defer func() { - c.activeRetriesMu.Lock() - delete(c.activeRetries, policy.Name) - c.activeRetriesMu.Unlock() - }() - - c.mu.Lock() - defer c.mu.Unlock() - - start := time.Now() - - // Build subject filter for this policy's resource type - apiGroup := policy.Spec.Resource.APIGroup - if apiGroup == "" { - apiGroup = "core" - } - kind := policy.Spec.Resource.Kind - - filter := &retryFilter{ - apiGroup: apiGroup, - kind: kind, - policyName: policy.Name, - maxPolicyVersion: policy.Generation, // Only retry events from older policy versions - } - - processed, succeeded, failed := c.processRetryBatch(ctx, "policy_update", filter) - dlqRetryBatchDuration.WithLabelValues("policy_update").Observe(time.Since(start).Seconds()) - - if processed > 0 { - klog.InfoS("Completed policy-triggered DLQ retry", - "policy", policy.Name, - "apiGroup", apiGroup, - "kind", kind, - "processed", processed, - "succeeded", succeeded, - "failed", failed, - "duration", time.Since(start), - ) - } -} - -// retryFilter defines criteria for filtering DLQ events. -type retryFilter struct { - apiGroup string - kind string - policyName string - maxPolicyVersion int64 // Only retry events with PolicyVersion < this -} - -// extractResourceInfo extracts apiGroup and kind from a DeadLetterEvent. -// Returns "core" for empty apiGroup and "unknown" for missing values. -func extractResourceInfo(event *processor.DeadLetterEvent) (apiGroup, kind string) { - apiGroup = "unknown" - kind = "unknown" - if event.Resource != nil { - if event.Resource.APIGroup != "" { - apiGroup = event.Resource.APIGroup - } else { - apiGroup = "core" - } - if event.Resource.Kind != "" { - kind = event.Resource.Kind - } - } - return apiGroup, kind -} - -// processRetryBatch fetches and processes retry-eligible DLQ events. -func (c *DLQRetryController) processRetryBatch(ctx context.Context, trigger string, filter *retryFilter) (processed, succeeded, failed int) { - // Build subject filter - subject := fmt.Sprintf("%s.>", c.dlqSubjectPrefix) - if filter != nil { - // Filter by specific apiGroup and kind - subject = fmt.Sprintf("%s.*.%s.%s", c.dlqSubjectPrefix, filter.apiGroup, filter.kind) - } - - // Create ephemeral consumer for this batch - sub, err := c.js.PullSubscribe( - subject, - "", // Durable name empty = ephemeral - nats.BindStream(c.dlqStreamName), - ) - if err != nil { - klog.ErrorS(err, "Failed to create DLQ consumer", "subject", subject) - return 0, 0, 0 - } - defer func() { - if err := sub.Unsubscribe(); err != nil { - klog.ErrorS(err, "Failed to unsubscribe from DLQ consumer") - } - }() - - // Fetch batch of messages - // Note: We use an ephemeral consumer that's destroyed after this batch. - // NAKed messages return to the stream and will be available to the next - // ephemeral consumer on the next retry interval. This is less efficient - // than server-side filtering but simpler to implement correctly. - msgs, err := sub.Fetch(c.config.BatchSize, nats.MaxWait(5*time.Second)) - if err != nil && err != nats.ErrTimeout { - klog.ErrorS(err, "Failed to fetch DLQ messages", "subject", subject) - return 0, 0, 0 - } - - now := time.Now() - - for _, msg := range msgs { - processed++ - - // Parse the DLQ event - var dlEvent processor.DeadLetterEvent - if err := json.Unmarshal(msg.Data, &dlEvent); err != nil { - klog.ErrorS(err, "Failed to unmarshal DLQ event") - if ackErr := msg.Ack(); ackErr != nil { - klog.ErrorS(ackErr, "Failed to ack corrupt DLQ event") - } - failed++ - continue - } - - // Extract resource info for metrics - apiGroup, kind := extractResourceInfo(&dlEvent) - - // Check filter criteria - if filter != nil { - // For policy-triggered retry, only retry events that: - // 1. Match the policy name (if specified) - // 2. Failed on an older policy version - if filter.policyName != "" && dlEvent.PolicyName != filter.policyName { - if nakErr := msg.Nak(); nakErr != nil { - klog.ErrorS(nakErr, "Failed to NAK DLQ message") - } - continue - } - if filter.maxPolicyVersion > 0 && dlEvent.PolicyVersion >= filter.maxPolicyVersion { - // Event failed on same or newer policy version, skip - if nakErr := msg.Nak(); nakErr != nil { - klog.ErrorS(nakErr, "Failed to NAK DLQ message") - } - continue - } - } else { - // For periodic retry, check backoff eligibility - if !c.isEligibleForRetry(&dlEvent, now) { - if nakErr := msg.Nak(); nakErr != nil { - klog.ErrorS(nakErr, "Failed to NAK DLQ message") - } - continue - } - } - - // Attempt to republish - if err := c.republishEvent(ctx, &dlEvent); err != nil { - klog.ErrorS(err, "Failed to republish DLQ event", - "eventType", dlEvent.Type, - "policy", dlEvent.PolicyName, - "retryCount", dlEvent.RetryCount, - ) - dlqRetryAttemptsTotal.WithLabelValues(trigger, apiGroup, kind, "failed").Inc() - failed++ - - // Update retry metadata and republish to DLQ - // Only ACK if metadata update succeeds, otherwise NAK to prevent data loss - if err := c.updateAndRepublishMetadata(ctx, &dlEvent, now); err != nil { - klog.ErrorS(err, "Failed to update retry metadata, NAKing to preserve event") - if nakErr := msg.Nak(); nakErr != nil { - klog.ErrorS(nakErr, "Failed to NAK DLQ message after metadata update failure") - } - continue - } - if ackErr := msg.Ack(); ackErr != nil { - klog.ErrorS(ackErr, "Failed to ack DLQ message after metadata update") - } - continue - } - - // Success - ack the DLQ message - if ackErr := msg.Ack(); ackErr != nil { - klog.ErrorS(ackErr, "Failed to ack successfully retried DLQ message") - } - dlqRetryAttemptsTotal.WithLabelValues(trigger, apiGroup, kind, "succeeded").Inc() - succeeded++ - - klog.V(2).InfoS("Successfully retried DLQ event", - "eventType", dlEvent.Type, - "policy", dlEvent.PolicyName, - "retryCount", dlEvent.RetryCount, - ) - } - - return processed, succeeded, failed -} - -// isEligibleForRetry checks if an event's backoff has expired. -func (c *DLQRetryController) isEligibleForRetry(event *processor.DeadLetterEvent, now time.Time) bool { - // First retry is always eligible - if event.NextRetryAfter == nil { - return true - } - return now.After(event.NextRetryAfter.Time) -} - -// republishEvent sends the original payload back to the source stream for reprocessing. -// The retry subjects (audit.k8s.retry and events.retry) must be captured by the corresponding -// NATS stream consumers. Ensure that: -// - AUDIT_LOGS stream includes subject "audit.k8s.retry" in its subject filter -// - EVENTS stream includes subject "events.retry" in its subject filter -// Without this configuration, retried events will not be picked up by processors. -func (c *DLQRetryController) republishEvent(ctx context.Context, event *processor.DeadLetterEvent) error { - // Determine target stream based on event type - var targetStream string - var subject string - - switch event.Type { - case processor.EventTypeAudit: - targetStream = c.auditStreamName - subject = c.config.AuditRetrySubject - case processor.EventTypeK8sEvent: - targetStream = c.eventStreamName - subject = c.config.EventRetrySubject - default: - return fmt.Errorf("unknown event type: %s", event.Type) - } - - // Use message ID for deduplication - include policy name and payload hash - // to ensure uniqueness across different policies and payloads - payloadHash := computePayloadHash(event.OriginalPayload) - msgID := fmt.Sprintf("dlq-retry-%s-%s-%s-%s-%d", - event.Type, - event.PolicyName, - payloadHash, - event.Timestamp.Format(time.RFC3339Nano), - event.RetryCount+1, - ) - - _, err := c.js.Publish( - subject, - event.OriginalPayload, - nats.MsgId(msgID), - nats.ExpectStream(targetStream), - ) - if err != nil { - return fmt.Errorf("failed to publish to %s: %w", targetStream, err) - } - - return nil -} - -// updateAndRepublishMetadata updates retry metadata and republishes to DLQ. -// Returns an error if the update fails - caller should NAK the original message. -func (c *DLQRetryController) updateAndRepublishMetadata(ctx context.Context, event *processor.DeadLetterEvent, now time.Time) error { - // Update retry metadata - event.RetryCount++ - nowTime := metav1.NewTime(now) - event.LastRetryAt = &nowTime - - // Calculate next retry time - backoff := c.calculateBackoff(event.RetryCount) - nextRetry := metav1.NewTime(now.Add(backoff)) - event.NextRetryAfter = &nextRetry - - // Track high retry count events - if event.RetryCount >= c.config.AlertThreshold { - apiGroup, kind := extractResourceInfo(event) - dlqEventsHighRetryTotal.WithLabelValues(apiGroup, kind, event.PolicyName).Inc() - } - - // Republish updated event to DLQ - data, err := json.Marshal(event) - if err != nil { - return fmt.Errorf("failed to marshal updated DLQ event: %w", err) - } - - // Build subject for updated event - apiGroup, kind := extractResourceInfo(event) - subject := fmt.Sprintf("%s.%s.%s.%s", c.dlqSubjectPrefix, event.Type, apiGroup, kind) - - // Use message ID for deduplication to prevent duplicates in DLQ - // Include policy name and payload hash for global uniqueness - payloadHash := computePayloadHash(event.OriginalPayload) - msgID := fmt.Sprintf("dlq-%s-%s-%s-%s-%d", - event.Type, - event.PolicyName, - payloadHash, - event.Timestamp.Format(time.RFC3339Nano), - event.RetryCount, - ) - - _, err = c.js.Publish(subject, data, nats.MsgId(msgID)) - if err != nil { - return fmt.Errorf("failed to republish updated DLQ event: %w", err) - } - return nil -} - -// calculateBackoff computes exponential backoff: min(base * multiplier^retryCount, max) -func (c *DLQRetryController) calculateBackoff(retryCount int) time.Duration { - // Cap exponent to prevent overflow (2^40 minutes = ~2 million years) - if retryCount > 40 { - return c.config.BackoffMax - } - - // Exponential backoff: base * multiplier^retryCount - multiplier := math.Pow(c.config.BackoffMultiplier, float64(retryCount)) - backoff := time.Duration(float64(c.config.BackoffBase) * multiplier) - - // Cap at maximum (also handles potential overflow to negative) - if backoff > c.config.BackoffMax || backoff < 0 { - return c.config.BackoffMax - } - return backoff -} - -// computePayloadHash computes a short hash of the payload for message ID uniqueness. -// Returns the first 8 characters of the SHA256 hash (32 bits of entropy). -func computePayloadHash(payload []byte) string { - hash := sha256.Sum256(payload) - return hex.EncodeToString(hash[:4]) // 4 bytes = 8 hex characters -} +package activityprocessor + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "math" + "sync" + "time" + + "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/metrics" + + "go.miloapis.com/activity/internal/processor" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +var ( + dlqRetryAttemptsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "dlq_retry", + Name: "attempts_total", + Help: "Total number of DLQ retry attempts", + }, + []string{"trigger", "api_group", "kind", "result"}, + ) + + dlqRetryBatchDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "activity_processor", + Subsystem: "dlq_retry", + Name: "batch_duration_seconds", + Help: "Duration of DLQ retry batch processing", + Buckets: prometheus.DefBuckets, + }, + []string{"trigger"}, + ) + + dlqEventsHighRetryTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "dlq_retry", + Name: "events_high_retry_total", + Help: "Total number of DLQ events that reached high retry threshold", + }, + []string{"api_group", "kind", "policy_name"}, + ) +) + +func init() { + metrics.Registry.MustRegister( + dlqRetryAttemptsTotal, + dlqRetryBatchDuration, + dlqEventsHighRetryTotal, + ) +} + +// DLQRetryConfig holds configuration for the DLQ retry controller. +type DLQRetryConfig struct { + // Enabled controls whether automatic retry is enabled. + Enabled bool + // Interval is how often to check for retry-eligible events. + Interval time.Duration + // BatchSize is how many events to process per batch. + BatchSize int + // BackoffBase is the initial backoff duration. + BackoffBase time.Duration + // BackoffMultiplier is the exponential multiplier (typically 2.0). + BackoffMultiplier float64 + // BackoffMax is the maximum backoff duration. + BackoffMax time.Duration + // AlertThreshold triggers metrics when retry count exceeds this. + AlertThreshold int + // AuditRetrySubject is the subject to republish audit events to. + AuditRetrySubject string + // EventRetrySubject is the subject to republish Kubernetes events to. + EventRetrySubject string +} + +// DefaultDLQRetryConfig returns sensible defaults for DLQ retry. +func DefaultDLQRetryConfig() DLQRetryConfig { + return DLQRetryConfig{ + Enabled: true, + Interval: 5 * time.Minute, + BatchSize: 100, + BackoffBase: 1 * time.Minute, + BackoffMultiplier: 2.0, + BackoffMax: 24 * time.Hour, + AlertThreshold: 10, + AuditRetrySubject: "audit.k8s.retry", + EventRetrySubject: "events.retry", + } +} + +// DLQRetryController manages automatic retry of dead-letter queue events. +type DLQRetryController struct { + js nats.JetStreamContext + config DLQRetryConfig + + auditStreamName string + eventStreamName string + dlqStreamName string + dlqSubjectPrefix string + + // mu protects concurrent access during policy-triggered retries + mu sync.Mutex + + // activeRetries tracks which policies have active retry operations + // to prevent concurrent retries for the same policy + activeRetries map[string]bool + activeRetriesMu sync.Mutex +} + +// NewDLQRetryController creates a new DLQ retry controller. +func NewDLQRetryController( + js nats.JetStreamContext, + config DLQRetryConfig, + auditStreamName string, + eventStreamName string, + dlqStreamName string, + dlqSubjectPrefix string, +) *DLQRetryController { + return &DLQRetryController{ + js: js, + config: config, + auditStreamName: auditStreamName, + eventStreamName: eventStreamName, + dlqStreamName: dlqStreamName, + dlqSubjectPrefix: dlqSubjectPrefix, + activeRetries: make(map[string]bool), + } +} + +// Start begins the retry controller with periodic retry. +func (c *DLQRetryController) Start(ctx context.Context) error { + if !c.config.Enabled { + klog.Info("DLQ retry controller is disabled") + return nil + } + + klog.InfoS("Starting DLQ retry controller", + "interval", c.config.Interval, + "batchSize", c.config.BatchSize, + "backoffBase", c.config.BackoffBase, + "backoffMax", c.config.BackoffMax, + ) + + ticker := time.NewTicker(c.config.Interval) + defer ticker.Stop() + + // Initial run + c.periodicRetry(ctx) + + for { + select { + case <-ctx.Done(): + klog.Info("DLQ retry controller stopping") + return nil + case <-ticker.C: + c.periodicRetry(ctx) + } + } +} + +// periodicRetry processes a batch of retry-eligible DLQ events. +func (c *DLQRetryController) periodicRetry(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + + start := time.Now() + processed, succeeded, failed := c.processRetryBatch(ctx, "periodic", nil) + dlqRetryBatchDuration.WithLabelValues("periodic").Observe(time.Since(start).Seconds()) + + if processed > 0 { + klog.InfoS("Completed periodic DLQ retry batch", + "processed", processed, + "succeeded", succeeded, + "failed", failed, + "duration", time.Since(start), + ) + } +} + +// RetryForPolicy triggers immediate retry for events that match a specific policy. +// This is called when an ActivityPolicy is updated. +func (c *DLQRetryController) RetryForPolicy(ctx context.Context, policy *v1alpha1.ActivityPolicy) { + if !c.config.Enabled || policy == nil { + return + } + + // Check if retry is already in progress for this policy + c.activeRetriesMu.Lock() + if c.activeRetries[policy.Name] { + c.activeRetriesMu.Unlock() + klog.V(2).InfoS("Retry already in progress for policy, skipping", + "policy", policy.Name) + return + } + c.activeRetries[policy.Name] = true + c.activeRetriesMu.Unlock() + + defer func() { + c.activeRetriesMu.Lock() + delete(c.activeRetries, policy.Name) + c.activeRetriesMu.Unlock() + }() + + c.mu.Lock() + defer c.mu.Unlock() + + start := time.Now() + + // Build subject filter for this policy's resource type + apiGroup := policy.Spec.Resource.APIGroup + if apiGroup == "" { + apiGroup = "core" + } + kind := policy.Spec.Resource.Kind + + filter := &retryFilter{ + apiGroup: apiGroup, + kind: kind, + policyName: policy.Name, + maxPolicyVersion: policy.Generation, // Only retry events from older policy versions + } + + processed, succeeded, failed := c.processRetryBatch(ctx, "policy_update", filter) + dlqRetryBatchDuration.WithLabelValues("policy_update").Observe(time.Since(start).Seconds()) + + if processed > 0 { + klog.InfoS("Completed policy-triggered DLQ retry", + "policy", policy.Name, + "apiGroup", apiGroup, + "kind", kind, + "processed", processed, + "succeeded", succeeded, + "failed", failed, + "duration", time.Since(start), + ) + } +} + +// retryFilter defines criteria for filtering DLQ events. +type retryFilter struct { + apiGroup string + kind string + policyName string + maxPolicyVersion int64 // Only retry events with PolicyVersion < this +} + +// extractResourceInfo extracts apiGroup and kind from a DeadLetterEvent. +// Returns "core" for empty apiGroup and "unknown" for missing values. +func extractResourceInfo(event *processor.DeadLetterEvent) (apiGroup, kind string) { + apiGroup = "unknown" + kind = "unknown" + if event.Resource != nil { + if event.Resource.APIGroup != "" { + apiGroup = event.Resource.APIGroup + } else { + apiGroup = "core" + } + if event.Resource.Kind != "" { + kind = event.Resource.Kind + } + } + return apiGroup, kind +} + +// processRetryBatch fetches and processes retry-eligible DLQ events. +func (c *DLQRetryController) processRetryBatch(ctx context.Context, trigger string, filter *retryFilter) (processed, succeeded, failed int) { + // Build subject filter + subject := fmt.Sprintf("%s.>", c.dlqSubjectPrefix) + if filter != nil { + // Filter by specific apiGroup and kind + subject = fmt.Sprintf("%s.*.%s.%s", c.dlqSubjectPrefix, filter.apiGroup, filter.kind) + } + + // Create ephemeral consumer for this batch + sub, err := c.js.PullSubscribe( + subject, + "", // Durable name empty = ephemeral + nats.BindStream(c.dlqStreamName), + ) + if err != nil { + klog.ErrorS(err, "Failed to create DLQ consumer", "subject", subject) + return 0, 0, 0 + } + defer func() { + if err := sub.Unsubscribe(); err != nil { + klog.ErrorS(err, "Failed to unsubscribe from DLQ consumer") + } + }() + + // Fetch batch of messages + // Note: We use an ephemeral consumer that's destroyed after this batch. + // NAKed messages return to the stream and will be available to the next + // ephemeral consumer on the next retry interval. This is less efficient + // than server-side filtering but simpler to implement correctly. + msgs, err := sub.Fetch(c.config.BatchSize, nats.MaxWait(5*time.Second)) + if err != nil && err != nats.ErrTimeout { + klog.ErrorS(err, "Failed to fetch DLQ messages", "subject", subject) + return 0, 0, 0 + } + + now := time.Now() + + for _, msg := range msgs { + processed++ + + // Parse the DLQ event + var dlEvent processor.DeadLetterEvent + if err := json.Unmarshal(msg.Data, &dlEvent); err != nil { + klog.ErrorS(err, "Failed to unmarshal DLQ event") + if ackErr := msg.Ack(); ackErr != nil { + klog.ErrorS(ackErr, "Failed to ack corrupt DLQ event") + } + failed++ + continue + } + + // Extract resource info for metrics + apiGroup, kind := extractResourceInfo(&dlEvent) + + // Check filter criteria + if filter != nil { + // For policy-triggered retry, only retry events that: + // 1. Match the policy name (if specified) + // 2. Failed on an older policy version + if filter.policyName != "" && dlEvent.PolicyName != filter.policyName { + if nakErr := msg.Nak(); nakErr != nil { + klog.ErrorS(nakErr, "Failed to NAK DLQ message") + } + continue + } + if filter.maxPolicyVersion > 0 && dlEvent.PolicyVersion >= filter.maxPolicyVersion { + // Event failed on same or newer policy version, skip + if nakErr := msg.Nak(); nakErr != nil { + klog.ErrorS(nakErr, "Failed to NAK DLQ message") + } + continue + } + } else { + // For periodic retry, check backoff eligibility + if !c.isEligibleForRetry(&dlEvent, now) { + if nakErr := msg.Nak(); nakErr != nil { + klog.ErrorS(nakErr, "Failed to NAK DLQ message") + } + continue + } + } + + // Attempt to republish + if err := c.republishEvent(ctx, &dlEvent); err != nil { + klog.ErrorS(err, "Failed to republish DLQ event", + "eventType", dlEvent.Type, + "policy", dlEvent.PolicyName, + "retryCount", dlEvent.RetryCount, + ) + dlqRetryAttemptsTotal.WithLabelValues(trigger, apiGroup, kind, "failed").Inc() + failed++ + + // Update retry metadata and republish to DLQ + // Only ACK if metadata update succeeds, otherwise NAK to prevent data loss + if err := c.updateAndRepublishMetadata(ctx, &dlEvent, now); err != nil { + klog.ErrorS(err, "Failed to update retry metadata, NAKing to preserve event") + if nakErr := msg.Nak(); nakErr != nil { + klog.ErrorS(nakErr, "Failed to NAK DLQ message after metadata update failure") + } + continue + } + if ackErr := msg.Ack(); ackErr != nil { + klog.ErrorS(ackErr, "Failed to ack DLQ message after metadata update") + } + continue + } + + // Success - ack the DLQ message + if ackErr := msg.Ack(); ackErr != nil { + klog.ErrorS(ackErr, "Failed to ack successfully retried DLQ message") + } + dlqRetryAttemptsTotal.WithLabelValues(trigger, apiGroup, kind, "succeeded").Inc() + succeeded++ + + klog.V(2).InfoS("Successfully retried DLQ event", + "eventType", dlEvent.Type, + "policy", dlEvent.PolicyName, + "retryCount", dlEvent.RetryCount, + ) + } + + return processed, succeeded, failed +} + +// isEligibleForRetry checks if an event's backoff has expired. +func (c *DLQRetryController) isEligibleForRetry(event *processor.DeadLetterEvent, now time.Time) bool { + // First retry is always eligible + if event.NextRetryAfter == nil { + return true + } + return now.After(event.NextRetryAfter.Time) +} + +// republishEvent sends the original payload back to the source stream for reprocessing. +// The retry subjects (audit.k8s.retry and events.retry) must be captured by the corresponding +// NATS stream consumers. Ensure that: +// - AUDIT_LOGS stream includes subject "audit.k8s.retry" in its subject filter +// - EVENTS stream includes subject "events.retry" in its subject filter +// Without this configuration, retried events will not be picked up by processors. +func (c *DLQRetryController) republishEvent(ctx context.Context, event *processor.DeadLetterEvent) error { + // Determine target stream based on event type + var targetStream string + var subject string + + switch event.Type { + case processor.EventTypeAudit: + targetStream = c.auditStreamName + subject = c.config.AuditRetrySubject + case processor.EventTypeK8sEvent: + targetStream = c.eventStreamName + subject = c.config.EventRetrySubject + default: + return fmt.Errorf("unknown event type: %s", event.Type) + } + + // Use message ID for deduplication - include policy name and payload hash + // to ensure uniqueness across different policies and payloads + payloadHash := computePayloadHash(event.OriginalPayload) + msgID := fmt.Sprintf("dlq-retry-%s-%s-%s-%s-%d", + event.Type, + event.PolicyName, + payloadHash, + event.Timestamp.Format(time.RFC3339Nano), + event.RetryCount+1, + ) + + _, err := c.js.Publish( + subject, + event.OriginalPayload, + nats.MsgId(msgID), + nats.ExpectStream(targetStream), + ) + if err != nil { + return fmt.Errorf("failed to publish to %s: %w", targetStream, err) + } + + return nil +} + +// updateAndRepublishMetadata updates retry metadata and republishes to DLQ. +// Returns an error if the update fails - caller should NAK the original message. +func (c *DLQRetryController) updateAndRepublishMetadata(ctx context.Context, event *processor.DeadLetterEvent, now time.Time) error { + // Update retry metadata + event.RetryCount++ + nowTime := metav1.NewTime(now) + event.LastRetryAt = &nowTime + + // Calculate next retry time + backoff := c.calculateBackoff(event.RetryCount) + nextRetry := metav1.NewTime(now.Add(backoff)) + event.NextRetryAfter = &nextRetry + + // Track high retry count events + if event.RetryCount >= c.config.AlertThreshold { + apiGroup, kind := extractResourceInfo(event) + dlqEventsHighRetryTotal.WithLabelValues(apiGroup, kind, event.PolicyName).Inc() + } + + // Republish updated event to DLQ + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal updated DLQ event: %w", err) + } + + // Build subject for updated event + apiGroup, kind := extractResourceInfo(event) + subject := fmt.Sprintf("%s.%s.%s.%s", c.dlqSubjectPrefix, event.Type, apiGroup, kind) + + // Use message ID for deduplication to prevent duplicates in DLQ + // Include policy name and payload hash for global uniqueness + payloadHash := computePayloadHash(event.OriginalPayload) + msgID := fmt.Sprintf("dlq-%s-%s-%s-%s-%d", + event.Type, + event.PolicyName, + payloadHash, + event.Timestamp.Format(time.RFC3339Nano), + event.RetryCount, + ) + + _, err = c.js.Publish(subject, data, nats.MsgId(msgID)) + if err != nil { + return fmt.Errorf("failed to republish updated DLQ event: %w", err) + } + return nil +} + +// calculateBackoff computes exponential backoff: min(base * multiplier^retryCount, max) +func (c *DLQRetryController) calculateBackoff(retryCount int) time.Duration { + // Cap exponent to prevent overflow (2^40 minutes = ~2 million years) + if retryCount > 40 { + return c.config.BackoffMax + } + + // Exponential backoff: base * multiplier^retryCount + multiplier := math.Pow(c.config.BackoffMultiplier, float64(retryCount)) + backoff := time.Duration(float64(c.config.BackoffBase) * multiplier) + + // Cap at maximum (also handles potential overflow to negative) + if backoff > c.config.BackoffMax || backoff < 0 { + return c.config.BackoffMax + } + return backoff +} + +// computePayloadHash computes a short hash of the payload for message ID uniqueness. +// Returns the first 8 characters of the SHA256 hash (32 bits of entropy). +func computePayloadHash(payload []byte) string { + hash := sha256.Sum256(payload) + return hex.EncodeToString(hash[:4]) // 4 bytes = 8 hex characters +} diff --git a/internal/activityprocessor/policycache.go b/internal/activityprocessor/policycache.go index 5f6aad6b..45f98127 100644 --- a/internal/activityprocessor/policycache.go +++ b/internal/activityprocessor/policycache.go @@ -1,516 +1,516 @@ -package activityprocessor - -import ( - "encoding/json" - "fmt" - "regexp" - "strings" - "sync" - - "github.com/google/cel-go/cel" - "k8s.io/klog/v2" - - internalcel "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/internal/processor" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// summaryTemplateRegex matches {{ expression }} patterns in summary templates. -var summaryTemplateRegex = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) - -// CompiledRule represents a pre-compiled policy rule ready for execution. -type CompiledRule struct { - // Match is the original match expression. - Match string - // Summary is the original summary template. - Summary string - // MatchProgram is the pre-compiled CEL program for match evaluation. - MatchProgram cel.Program - // SummaryTemplates contains pre-compiled CEL programs for each template expression. - SummaryTemplates []compiledTemplate - // Valid indicates if the rule compiled successfully. - Valid bool - // CompileError holds any error from compilation. - CompileError string -} - -// compiledTemplate represents a single {{ expression }} in a summary template. -type compiledTemplate struct { - // FullMatch is the original {{ expression }} string - FullMatch string - // Expression is the CEL expression without {{ }} - Expression string - // Program is the pre-compiled CEL program - Program cel.Program -} - -// CompiledPolicy represents a pre-compiled ActivityPolicy ready for execution. -type CompiledPolicy struct { - // Name is the policy name. - Name string - // APIGroup is the target resource's API group. - APIGroup string - // Kind is the target resource's kind. - Kind string - // Resource is the plural resource name (for audit event matching). - Resource string - // AuditRules are the compiled audit rules. - AuditRules []CompiledRule - // EventRules are the compiled event rules. - EventRules []CompiledRule - // ResourceVersion is the policy's resource version for cache invalidation. - ResourceVersion string - // OriginalPolicy is the original policy for metrics and logging. - OriginalPolicy *v1alpha1.ActivityPolicy -} - -// PolicyCache provides thread-safe caching of pre-compiled ActivityPolicy resources. -type PolicyCache struct { - mu sync.RWMutex - - // policies stores compiled policies indexed by apiGroup/resource (plural) - // Multiple policies can target the same resource. - policies map[string][]*CompiledPolicy - - // policiesByKind stores compiled policies indexed by apiGroup/kind - // for event lookups since events use Kind not Resource. - policiesByKind map[string][]*CompiledPolicy -} - -// NewPolicyCache creates a new policy cache. -func NewPolicyCache() *PolicyCache { - return &PolicyCache{ - policies: make(map[string][]*CompiledPolicy), - policiesByKind: make(map[string][]*CompiledPolicy), - } -} - -// Add compiles and adds a policy to the cache. -func (c *PolicyCache) Add(policy *v1alpha1.ActivityPolicy, resource string) error { - compiled, err := c.compile(policy, resource) - if err != nil { - return err - } - - c.mu.Lock() - defer c.mu.Unlock() - - // Index by apiGroup/resource for audit lookups - key := policyKey(policy.Spec.Resource.APIGroup, resource) - c.policies[key] = append(c.policies[key], compiled) - - // Index by apiGroup/kind for event lookups - kindKey := policyKey(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) - c.policiesByKind[kindKey] = append(c.policiesByKind[kindKey], compiled) - - klog.V(2).InfoS("Added compiled policy to cache", - "policy", policy.Name, - "key", key, - "kindKey", kindKey, - "auditRules", len(compiled.AuditRules), - "eventRules", len(compiled.EventRules), - ) - - return nil -} - -// Update removes the old policy and adds the new one. -func (c *PolicyCache) Update(oldPolicy, newPolicy *v1alpha1.ActivityPolicy, oldResource, newResource string) error { - c.mu.Lock() - defer c.mu.Unlock() - - // Remove old policy from both indexes - oldKey := policyKey(oldPolicy.Spec.Resource.APIGroup, oldResource) - c.removeLocked(oldKey, oldPolicy.Name) - oldKindKey := policyKey(oldPolicy.Spec.Resource.APIGroup, oldPolicy.Spec.Resource.Kind) - c.removeKindLocked(oldKindKey, oldPolicy.Name) - - // Compile and add new policy - compiled, err := c.compile(newPolicy, newResource) - if err != nil { - return err - } - - newKey := policyKey(newPolicy.Spec.Resource.APIGroup, newResource) - c.policies[newKey] = append(c.policies[newKey], compiled) - - newKindKey := policyKey(newPolicy.Spec.Resource.APIGroup, newPolicy.Spec.Resource.Kind) - c.policiesByKind[newKindKey] = append(c.policiesByKind[newKindKey], compiled) - - klog.V(2).InfoS("Updated compiled policy in cache", - "policy", newPolicy.Name, - "oldKey", oldKey, - "newKey", newKey, - "oldKindKey", oldKindKey, - "newKindKey", newKindKey, - ) - - return nil -} - -// Remove removes a policy from the cache. -func (c *PolicyCache) Remove(policy *v1alpha1.ActivityPolicy, resource string) { - c.mu.Lock() - defer c.mu.Unlock() - - key := policyKey(policy.Spec.Resource.APIGroup, resource) - c.removeLocked(key, policy.Name) - - kindKey := policyKey(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) - c.removeKindLocked(kindKey, policy.Name) - - klog.V(2).InfoS("Removed policy from cache", "policy", policy.Name, "key", key, "kindKey", kindKey) -} - -// removeLocked removes a policy by name from a key. Caller must hold the lock. -func (c *PolicyCache) removeLocked(key, policyName string) { - policies := c.policies[key] - for i, p := range policies { - if p.Name == policyName { - // O(1) removal: swap with last element and truncate. - policies[i] = policies[len(policies)-1] - c.policies[key] = policies[:len(policies)-1] - break - } - } - if len(c.policies[key]) == 0 { - delete(c.policies, key) - } -} - -// removeKindLocked removes a policy by name from the kind index. Caller must hold the lock. -func (c *PolicyCache) removeKindLocked(kindKey, policyName string) { - policies := c.policiesByKind[kindKey] - for i, p := range policies { - if p.Name == policyName { - // O(1) removal: swap with last element and truncate. - policies[i] = policies[len(policies)-1] - c.policiesByKind[kindKey] = policies[:len(policies)-1] - break - } - } - if len(c.policiesByKind[kindKey]) == 0 { - delete(c.policiesByKind, kindKey) - } -} - -// Get returns compiled policies for a given apiGroup and resource. -func (c *PolicyCache) Get(apiGroup, resource string) []*CompiledPolicy { - c.mu.RLock() - defer c.mu.RUnlock() - - key := policyKey(apiGroup, resource) - return c.policies[key] -} - -// GetByKind returns compiled policies for a given apiGroup and kind. -// Used by event processing since events reference Kind not Resource. -func (c *PolicyCache) GetByKind(apiGroup, kind string) []*CompiledPolicy { - c.mu.RLock() - defer c.mu.RUnlock() - - key := policyKey(apiGroup, kind) - return c.policiesByKind[key] -} - -// Len returns the total number of policies in the cache. -func (c *PolicyCache) Len() int { - c.mu.RLock() - defer c.mu.RUnlock() - - count := 0 - for _, policies := range c.policies { - count += len(policies) - } - return count -} - -// compile compiles an ActivityPolicy into a CompiledPolicy. -func (c *PolicyCache) compile(policy *v1alpha1.ActivityPolicy, resource string) (*CompiledPolicy, error) { - compiled := &CompiledPolicy{ - Name: policy.Name, - APIGroup: policy.Spec.Resource.APIGroup, - Kind: policy.Spec.Resource.Kind, - Resource: resource, - ResourceVersion: policy.ResourceVersion, - AuditRules: make([]CompiledRule, len(policy.Spec.AuditRules)), - EventRules: make([]CompiledRule, len(policy.Spec.EventRules)), - OriginalPolicy: policy.DeepCopy(), - } - - // Compile audit rules - for i, rule := range policy.Spec.AuditRules { - compiledRule := c.compileAuditRule(rule, policy.Name, i) - compiled.AuditRules[i] = compiledRule - } - - // Compile event rules - for i, rule := range policy.Spec.EventRules { - compiledRule := c.compileEventRule(rule, policy.Name, i) - compiled.EventRules[i] = compiledRule - } - - return compiled, nil -} - -// compileAuditRule compiles a single audit rule. -func (c *PolicyCache) compileAuditRule(rule v1alpha1.ActivityPolicyRule, policyName string, ruleIndex int) CompiledRule { - compiled := CompiledRule{ - Match: rule.Match, - Summary: rule.Summary, - Valid: true, - } - - // Create audit environment for compilation - env, err := auditEnvironment() - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("failed to create CEL environment: %v", err) - klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - - // Compile match expression - matchAST, issues := env.Compile(rule.Match) - if issues != nil && issues.Err() != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("match: %v", issues.Err()) - klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - - matchProgram, err := env.Program(matchAST) - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("match program: %v", err) - klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - compiled.MatchProgram = matchProgram - - // Compile summary template expressions - templates, err := compileSummaryTemplate(env, rule.Summary) - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("summary: %v", err) - klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - compiled.SummaryTemplates = templates - - return compiled -} - -// compileEventRule compiles a single event rule. -func (c *PolicyCache) compileEventRule(rule v1alpha1.ActivityPolicyRule, policyName string, ruleIndex int) CompiledRule { - compiled := CompiledRule{ - Match: rule.Match, - Summary: rule.Summary, - Valid: true, - } - - // Create event environment for compilation - env, err := eventEnvironment() - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("failed to create CEL environment: %v", err) - klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - - // Compile match expression - matchAST, issues := env.Compile(rule.Match) - if issues != nil && issues.Err() != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("match: %v", issues.Err()) - klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - - matchProgram, err := env.Program(matchAST) - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("match program: %v", err) - klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - compiled.MatchProgram = matchProgram - - // Compile summary template expressions - templates, err := compileSummaryTemplate(env, rule.Summary) - if err != nil { - compiled.Valid = false - compiled.CompileError = fmt.Sprintf("summary: %v", err) - klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) - return compiled - } - compiled.SummaryTemplates = templates - - return compiled -} - -// compileSummaryTemplate compiles all {{ expression }} blocks in a summary template. -func compileSummaryTemplate(env *cel.Env, template string) ([]compiledTemplate, error) { - matches := summaryTemplateRegex.FindAllStringSubmatch(template, -1) - if len(matches) == 0 { - return nil, nil - } - - templates := make([]compiledTemplate, 0, len(matches)) - for _, match := range matches { - if len(match) < 2 { - continue - } - - expr := strings.TrimSpace(match[1]) - if expr == "" { - return nil, fmt.Errorf("empty expression in template: %s", match[0]) - } - - ast, issues := env.Compile(expr) - if issues != nil && issues.Err() != nil { - return nil, fmt.Errorf("expression '%s': %w", expr, issues.Err()) - } - - prg, err := env.Program(ast) - if err != nil { - return nil, fmt.Errorf("expression '%s': %w", expr, err) - } - - templates = append(templates, compiledTemplate{ - FullMatch: match[0], - Expression: expr, - Program: prg, - }) - } - - return templates, nil -} - -// auditEnvironment creates a CEL environment for audit rule expressions. -// Uses the shared environment from internal/cel package. -func auditEnvironment() (*cel.Env, error) { - return internalcel.NewAuditEnvironment(nil) -} - -// eventEnvironment creates a CEL environment for event rule expressions. -// Uses the shared environment from internal/cel package. -func eventEnvironment() (*cel.Env, error) { - return internalcel.NewEventEnvironment(nil) -} - -// EvaluateAuditRules evaluates audit rules against an audit event using pre-compiled programs. -// Returns the index of the matching rule, the generated summary, and whether a match was found. -func (r *CompiledRule) EvaluateAuditMatch(auditMap map[string]any) (bool, error) { - if !r.Valid || r.MatchProgram == nil { - return false, nil - } - - vars := internalcel.BuildAuditVars(auditMap) - - out, _, err := r.MatchProgram.Eval(vars) - if err != nil { - return false, fmt.Errorf("failed to evaluate match: %w", err) - } - - result, ok := out.Value().(bool) - if !ok { - return false, fmt.Errorf("match expression did not return boolean") - } - - return result, nil -} - -// EvaluateSummary evaluates the summary template using pre-compiled programs. -func (r *CompiledRule) EvaluateSummary(vars map[string]any) (string, error) { - if len(r.SummaryTemplates) == 0 { - return r.Summary, nil - } - - result := r.Summary - for _, tmpl := range r.SummaryTemplates { - out, _, err := tmpl.Program.Eval(vars) - if err != nil { - return "", fmt.Errorf("failed to evaluate summary expression '%s': %w", tmpl.Expression, err) - } - result = strings.Replace(result, tmpl.FullMatch, fmt.Sprintf("%v", out.Value()), 1) - } - - return result, nil -} - -// EvaluateEventMatch evaluates the match expression against a Kubernetes event. -func (r *CompiledRule) EvaluateEventMatch(eventMap map[string]any) (bool, error) { - if !r.Valid || r.MatchProgram == nil { - return false, nil - } - - vars := internalcel.BuildEventVars(eventMap) - - out, _, err := r.MatchProgram.Eval(vars) - if err != nil { - return false, fmt.Errorf("failed to evaluate match: %w", err) - } - - result, ok := out.Value().(bool) - if !ok { - return false, fmt.Errorf("match expression did not return boolean") - } - - return result, nil -} - -// MatchEvent implements processor.EventPolicyLookup. -// It looks up matching event rules for the given apiGroup/kind and evaluates them -// against the provided event map. Returns the first matching result, or nil if no policy matched. -func (c *PolicyCache) MatchEvent(apiGroup, kind string, eventMap map[string]any) (*processor.MatchedPolicy, error) { - policies := c.GetByKind(apiGroup, kind) - if len(policies) == 0 { - return nil, nil - } - - // First matching policy wins - for _, policy := range policies { - for i := range policy.EventRules { - rule := &policy.EventRules[i] - if !rule.Valid { - continue - } - - // Evaluate match expression - matched, err := rule.EvaluateEventMatch(eventMap) - if err != nil { - eventJSON, _ := json.Marshal(eventMap) - klog.V(2).InfoS("Failed to evaluate event match", - "policy", policy.Name, - "ruleIndex", i, - "error", err, - "eventJSON", truncateString(string(eventJSON), 4096), - ) - continue - } - - if matched { - // Evaluate summary using internalcel.EvaluateEventSummary for proper link collection - summary, links, err := internalcel.EvaluateEventSummary(rule.Summary, eventMap) - if err != nil { - return nil, processor.NewPolicyEvaluationError( - policy.Name, i, - fmt.Errorf("failed to evaluate summary: %w", err), - ) - } - - return &processor.MatchedPolicy{ - PolicyName: policy.Name, - Generation: policy.OriginalPolicy.Generation, - APIGroup: policy.APIGroup, - Kind: policy.Kind, - Summary: summary, - Links: links, - }, nil - } - } - } - - return nil, nil -} +package activityprocessor + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/google/cel-go/cel" + "k8s.io/klog/v2" + + internalcel "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/internal/processor" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// summaryTemplateRegex matches {{ expression }} patterns in summary templates. +var summaryTemplateRegex = regexp.MustCompile(`\{\{\s*(.+?)\s*\}\}`) + +// CompiledRule represents a pre-compiled policy rule ready for execution. +type CompiledRule struct { + // Match is the original match expression. + Match string + // Summary is the original summary template. + Summary string + // MatchProgram is the pre-compiled CEL program for match evaluation. + MatchProgram cel.Program + // SummaryTemplates contains pre-compiled CEL programs for each template expression. + SummaryTemplates []compiledTemplate + // Valid indicates if the rule compiled successfully. + Valid bool + // CompileError holds any error from compilation. + CompileError string +} + +// compiledTemplate represents a single {{ expression }} in a summary template. +type compiledTemplate struct { + // FullMatch is the original {{ expression }} string + FullMatch string + // Expression is the CEL expression without {{ }} + Expression string + // Program is the pre-compiled CEL program + Program cel.Program +} + +// CompiledPolicy represents a pre-compiled ActivityPolicy ready for execution. +type CompiledPolicy struct { + // Name is the policy name. + Name string + // APIGroup is the target resource's API group. + APIGroup string + // Kind is the target resource's kind. + Kind string + // Resource is the plural resource name (for audit event matching). + Resource string + // AuditRules are the compiled audit rules. + AuditRules []CompiledRule + // EventRules are the compiled event rules. + EventRules []CompiledRule + // ResourceVersion is the policy's resource version for cache invalidation. + ResourceVersion string + // OriginalPolicy is the original policy for metrics and logging. + OriginalPolicy *v1alpha1.ActivityPolicy +} + +// PolicyCache provides thread-safe caching of pre-compiled ActivityPolicy resources. +type PolicyCache struct { + mu sync.RWMutex + + // policies stores compiled policies indexed by apiGroup/resource (plural) + // Multiple policies can target the same resource. + policies map[string][]*CompiledPolicy + + // policiesByKind stores compiled policies indexed by apiGroup/kind + // for event lookups since events use Kind not Resource. + policiesByKind map[string][]*CompiledPolicy +} + +// NewPolicyCache creates a new policy cache. +func NewPolicyCache() *PolicyCache { + return &PolicyCache{ + policies: make(map[string][]*CompiledPolicy), + policiesByKind: make(map[string][]*CompiledPolicy), + } +} + +// Add compiles and adds a policy to the cache. +func (c *PolicyCache) Add(policy *v1alpha1.ActivityPolicy, resource string) error { + compiled, err := c.compile(policy, resource) + if err != nil { + return err + } + + c.mu.Lock() + defer c.mu.Unlock() + + // Index by apiGroup/resource for audit lookups + key := policyKey(policy.Spec.Resource.APIGroup, resource) + c.policies[key] = append(c.policies[key], compiled) + + // Index by apiGroup/kind for event lookups + kindKey := policyKey(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) + c.policiesByKind[kindKey] = append(c.policiesByKind[kindKey], compiled) + + klog.V(2).InfoS("Added compiled policy to cache", + "policy", policy.Name, + "key", key, + "kindKey", kindKey, + "auditRules", len(compiled.AuditRules), + "eventRules", len(compiled.EventRules), + ) + + return nil +} + +// Update removes the old policy and adds the new one. +func (c *PolicyCache) Update(oldPolicy, newPolicy *v1alpha1.ActivityPolicy, oldResource, newResource string) error { + c.mu.Lock() + defer c.mu.Unlock() + + // Remove old policy from both indexes + oldKey := policyKey(oldPolicy.Spec.Resource.APIGroup, oldResource) + c.removeLocked(oldKey, oldPolicy.Name) + oldKindKey := policyKey(oldPolicy.Spec.Resource.APIGroup, oldPolicy.Spec.Resource.Kind) + c.removeKindLocked(oldKindKey, oldPolicy.Name) + + // Compile and add new policy + compiled, err := c.compile(newPolicy, newResource) + if err != nil { + return err + } + + newKey := policyKey(newPolicy.Spec.Resource.APIGroup, newResource) + c.policies[newKey] = append(c.policies[newKey], compiled) + + newKindKey := policyKey(newPolicy.Spec.Resource.APIGroup, newPolicy.Spec.Resource.Kind) + c.policiesByKind[newKindKey] = append(c.policiesByKind[newKindKey], compiled) + + klog.V(2).InfoS("Updated compiled policy in cache", + "policy", newPolicy.Name, + "oldKey", oldKey, + "newKey", newKey, + "oldKindKey", oldKindKey, + "newKindKey", newKindKey, + ) + + return nil +} + +// Remove removes a policy from the cache. +func (c *PolicyCache) Remove(policy *v1alpha1.ActivityPolicy, resource string) { + c.mu.Lock() + defer c.mu.Unlock() + + key := policyKey(policy.Spec.Resource.APIGroup, resource) + c.removeLocked(key, policy.Name) + + kindKey := policyKey(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) + c.removeKindLocked(kindKey, policy.Name) + + klog.V(2).InfoS("Removed policy from cache", "policy", policy.Name, "key", key, "kindKey", kindKey) +} + +// removeLocked removes a policy by name from a key. Caller must hold the lock. +func (c *PolicyCache) removeLocked(key, policyName string) { + policies := c.policies[key] + for i, p := range policies { + if p.Name == policyName { + // O(1) removal: swap with last element and truncate. + policies[i] = policies[len(policies)-1] + c.policies[key] = policies[:len(policies)-1] + break + } + } + if len(c.policies[key]) == 0 { + delete(c.policies, key) + } +} + +// removeKindLocked removes a policy by name from the kind index. Caller must hold the lock. +func (c *PolicyCache) removeKindLocked(kindKey, policyName string) { + policies := c.policiesByKind[kindKey] + for i, p := range policies { + if p.Name == policyName { + // O(1) removal: swap with last element and truncate. + policies[i] = policies[len(policies)-1] + c.policiesByKind[kindKey] = policies[:len(policies)-1] + break + } + } + if len(c.policiesByKind[kindKey]) == 0 { + delete(c.policiesByKind, kindKey) + } +} + +// Get returns compiled policies for a given apiGroup and resource. +func (c *PolicyCache) Get(apiGroup, resource string) []*CompiledPolicy { + c.mu.RLock() + defer c.mu.RUnlock() + + key := policyKey(apiGroup, resource) + return c.policies[key] +} + +// GetByKind returns compiled policies for a given apiGroup and kind. +// Used by event processing since events reference Kind not Resource. +func (c *PolicyCache) GetByKind(apiGroup, kind string) []*CompiledPolicy { + c.mu.RLock() + defer c.mu.RUnlock() + + key := policyKey(apiGroup, kind) + return c.policiesByKind[key] +} + +// Len returns the total number of policies in the cache. +func (c *PolicyCache) Len() int { + c.mu.RLock() + defer c.mu.RUnlock() + + count := 0 + for _, policies := range c.policies { + count += len(policies) + } + return count +} + +// compile compiles an ActivityPolicy into a CompiledPolicy. +func (c *PolicyCache) compile(policy *v1alpha1.ActivityPolicy, resource string) (*CompiledPolicy, error) { + compiled := &CompiledPolicy{ + Name: policy.Name, + APIGroup: policy.Spec.Resource.APIGroup, + Kind: policy.Spec.Resource.Kind, + Resource: resource, + ResourceVersion: policy.ResourceVersion, + AuditRules: make([]CompiledRule, len(policy.Spec.AuditRules)), + EventRules: make([]CompiledRule, len(policy.Spec.EventRules)), + OriginalPolicy: policy.DeepCopy(), + } + + // Compile audit rules + for i, rule := range policy.Spec.AuditRules { + compiledRule := c.compileAuditRule(rule, policy.Name, i) + compiled.AuditRules[i] = compiledRule + } + + // Compile event rules + for i, rule := range policy.Spec.EventRules { + compiledRule := c.compileEventRule(rule, policy.Name, i) + compiled.EventRules[i] = compiledRule + } + + return compiled, nil +} + +// compileAuditRule compiles a single audit rule. +func (c *PolicyCache) compileAuditRule(rule v1alpha1.ActivityPolicyRule, policyName string, ruleIndex int) CompiledRule { + compiled := CompiledRule{ + Match: rule.Match, + Summary: rule.Summary, + Valid: true, + } + + // Create audit environment for compilation + env, err := auditEnvironment() + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("failed to create CEL environment: %v", err) + klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + + // Compile match expression + matchAST, issues := env.Compile(rule.Match) + if issues != nil && issues.Err() != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("match: %v", issues.Err()) + klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + + matchProgram, err := env.Program(matchAST) + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("match program: %v", err) + klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + compiled.MatchProgram = matchProgram + + // Compile summary template expressions + templates, err := compileSummaryTemplate(env, rule.Summary) + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("summary: %v", err) + klog.Warningf("Policy %s audit rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + compiled.SummaryTemplates = templates + + return compiled +} + +// compileEventRule compiles a single event rule. +func (c *PolicyCache) compileEventRule(rule v1alpha1.ActivityPolicyRule, policyName string, ruleIndex int) CompiledRule { + compiled := CompiledRule{ + Match: rule.Match, + Summary: rule.Summary, + Valid: true, + } + + // Create event environment for compilation + env, err := eventEnvironment() + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("failed to create CEL environment: %v", err) + klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + + // Compile match expression + matchAST, issues := env.Compile(rule.Match) + if issues != nil && issues.Err() != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("match: %v", issues.Err()) + klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + + matchProgram, err := env.Program(matchAST) + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("match program: %v", err) + klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + compiled.MatchProgram = matchProgram + + // Compile summary template expressions + templates, err := compileSummaryTemplate(env, rule.Summary) + if err != nil { + compiled.Valid = false + compiled.CompileError = fmt.Sprintf("summary: %v", err) + klog.Warningf("Policy %s event rule %d: %s", policyName, ruleIndex, compiled.CompileError) + return compiled + } + compiled.SummaryTemplates = templates + + return compiled +} + +// compileSummaryTemplate compiles all {{ expression }} blocks in a summary template. +func compileSummaryTemplate(env *cel.Env, template string) ([]compiledTemplate, error) { + matches := summaryTemplateRegex.FindAllStringSubmatch(template, -1) + if len(matches) == 0 { + return nil, nil + } + + templates := make([]compiledTemplate, 0, len(matches)) + for _, match := range matches { + if len(match) < 2 { + continue + } + + expr := strings.TrimSpace(match[1]) + if expr == "" { + return nil, fmt.Errorf("empty expression in template: %s", match[0]) + } + + ast, issues := env.Compile(expr) + if issues != nil && issues.Err() != nil { + return nil, fmt.Errorf("expression '%s': %w", expr, issues.Err()) + } + + prg, err := env.Program(ast) + if err != nil { + return nil, fmt.Errorf("expression '%s': %w", expr, err) + } + + templates = append(templates, compiledTemplate{ + FullMatch: match[0], + Expression: expr, + Program: prg, + }) + } + + return templates, nil +} + +// auditEnvironment creates a CEL environment for audit rule expressions. +// Uses the shared environment from internal/cel package. +func auditEnvironment() (*cel.Env, error) { + return internalcel.NewAuditEnvironment(nil) +} + +// eventEnvironment creates a CEL environment for event rule expressions. +// Uses the shared environment from internal/cel package. +func eventEnvironment() (*cel.Env, error) { + return internalcel.NewEventEnvironment(nil) +} + +// EvaluateAuditRules evaluates audit rules against an audit event using pre-compiled programs. +// Returns the index of the matching rule, the generated summary, and whether a match was found. +func (r *CompiledRule) EvaluateAuditMatch(auditMap map[string]any) (bool, error) { + if !r.Valid || r.MatchProgram == nil { + return false, nil + } + + vars := internalcel.BuildAuditVars(auditMap) + + out, _, err := r.MatchProgram.Eval(vars) + if err != nil { + return false, fmt.Errorf("failed to evaluate match: %w", err) + } + + result, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("match expression did not return boolean") + } + + return result, nil +} + +// EvaluateSummary evaluates the summary template using pre-compiled programs. +func (r *CompiledRule) EvaluateSummary(vars map[string]any) (string, error) { + if len(r.SummaryTemplates) == 0 { + return r.Summary, nil + } + + result := r.Summary + for _, tmpl := range r.SummaryTemplates { + out, _, err := tmpl.Program.Eval(vars) + if err != nil { + return "", fmt.Errorf("failed to evaluate summary expression '%s': %w", tmpl.Expression, err) + } + result = strings.Replace(result, tmpl.FullMatch, fmt.Sprintf("%v", out.Value()), 1) + } + + return result, nil +} + +// EvaluateEventMatch evaluates the match expression against a Kubernetes event. +func (r *CompiledRule) EvaluateEventMatch(eventMap map[string]any) (bool, error) { + if !r.Valid || r.MatchProgram == nil { + return false, nil + } + + vars := internalcel.BuildEventVars(eventMap) + + out, _, err := r.MatchProgram.Eval(vars) + if err != nil { + return false, fmt.Errorf("failed to evaluate match: %w", err) + } + + result, ok := out.Value().(bool) + if !ok { + return false, fmt.Errorf("match expression did not return boolean") + } + + return result, nil +} + +// MatchEvent implements processor.EventPolicyLookup. +// It looks up matching event rules for the given apiGroup/kind and evaluates them +// against the provided event map. Returns the first matching result, or nil if no policy matched. +func (c *PolicyCache) MatchEvent(apiGroup, kind string, eventMap map[string]any) (*processor.MatchedPolicy, error) { + policies := c.GetByKind(apiGroup, kind) + if len(policies) == 0 { + return nil, nil + } + + // First matching policy wins + for _, policy := range policies { + for i := range policy.EventRules { + rule := &policy.EventRules[i] + if !rule.Valid { + continue + } + + // Evaluate match expression + matched, err := rule.EvaluateEventMatch(eventMap) + if err != nil { + eventJSON, _ := json.Marshal(eventMap) + klog.V(2).InfoS("Failed to evaluate event match", + "policy", policy.Name, + "ruleIndex", i, + "error", err, + "eventJSON", truncateString(string(eventJSON), 4096), + ) + continue + } + + if matched { + // Evaluate summary using internalcel.EvaluateEventSummary for proper link collection + summary, links, err := internalcel.EvaluateEventSummary(rule.Summary, eventMap) + if err != nil { + return nil, processor.NewPolicyEvaluationError( + policy.Name, i, + fmt.Errorf("failed to evaluate summary: %w", err), + ) + } + + return &processor.MatchedPolicy{ + PolicyName: policy.Name, + Generation: policy.OriginalPolicy.Generation, + APIGroup: policy.APIGroup, + Kind: policy.Kind, + Summary: summary, + Links: links, + }, nil + } + } + } + + return nil, nil +} diff --git a/internal/activityprocessor/processor.go b/internal/activityprocessor/processor.go index 339f4ea9..8d153904 100644 --- a/internal/activityprocessor/processor.go +++ b/internal/activityprocessor/processor.go @@ -1,1329 +1,1329 @@ -package activityprocessor - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "errors" - "fmt" - "net/http" - "os" - "sync" - "time" - - "github.com/nats-io/nats.go" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/discovery" - "k8s.io/client-go/discovery/cached/memory" - "k8s.io/client-go/kubernetes" - typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" - "k8s.io/client-go/rest" - "k8s.io/client-go/restmapper" - toolscache "k8s.io/client-go/tools/cache" - "k8s.io/client-go/tools/record" - "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/cache" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/healthz" - "sigs.k8s.io/controller-runtime/pkg/metrics" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/internal/controller" - "go.miloapis.com/activity/internal/processor" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -var ( - eventsReceived = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Name: "events_received_total", - Help: "Total number of events received from NATS", - }, - []string{"source", "api_group", "resource"}, - ) - - eventsEvaluated = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Name: "events_evaluated_total", - Help: "Total number of events evaluated against policies", - }, - []string{"source", "policy", "api_group", "kind", "matched"}, - ) - - eventsSkipped = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Name: "events_skipped_total", - Help: "Total number of events skipped", - }, - []string{"source", "reason"}, - ) - - eventsErrored = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Name: "events_errored_total", - Help: "Total number of events that encountered errors", - }, - []string{"source", "error_type"}, - ) - - activitiesGenerated = prometheus.NewCounterVec( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Name: "activities_generated_total", - Help: "Total number of activities generated and published", - }, - []string{"policy", "api_group", "kind"}, - ) - - eventProcessingDuration = prometheus.NewHistogramVec( - prometheus.HistogramOpts{ - Namespace: "activity_processor", - Name: "event_processing_duration_seconds", - Help: "Time spent processing events per policy", - Buckets: prometheus.DefBuckets, - }, - []string{"source", "policy"}, - ) - - policyCount = prometheus.NewGauge( - prometheus.GaugeOpts{ - Namespace: "activity_processor", - Name: "active_policies", - Help: "Number of active (Ready) policies being used", - }, - ) - - workerCount = prometheus.NewGauge( - prometheus.GaugeOpts{ - Namespace: "activity_processor", - Name: "active_workers", - Help: "Number of active worker goroutines", - }, - ) - - // NATS client metrics - natsConnectionStatus = prometheus.NewGauge( - prometheus.GaugeOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "connection_status", - Help: "NATS connection status (1 = connected, 0 = disconnected)", - }, - ) - - natsDisconnectsTotal = prometheus.NewCounter( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "disconnects_total", - Help: "Total number of NATS disconnection events", - }, - ) - - natsReconnectsTotal = prometheus.NewCounter( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "reconnects_total", - Help: "Total number of NATS reconnection events", - }, - ) - - natsErrorsTotal = prometheus.NewCounter( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "errors_total", - Help: "Total number of NATS async errors", - }, - ) - - natsMessagesPublished = prometheus.NewCounter( - prometheus.CounterOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "messages_published_total", - Help: "Total number of messages published to NATS", - }, - ) - - natsPublishLatency = prometheus.NewHistogram( - prometheus.HistogramOpts{ - Namespace: "activity_processor", - Subsystem: "nats", - Name: "publish_latency_seconds", - Help: "Latency of NATS publish operations", - Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1}, - }, - ) - -) - -func init() { - // Use controller-runtime's registry so metrics are exposed alongside controller metrics. - metrics.Registry.MustRegister( - eventsReceived, - eventsEvaluated, - eventsSkipped, - eventsErrored, - activitiesGenerated, - eventProcessingDuration, - policyCount, - workerCount, - // NATS metrics - natsConnectionStatus, - natsDisconnectsTotal, - natsReconnectsTotal, - natsErrorsTotal, - natsMessagesPublished, - natsPublishLatency, - ) -} - -// Config contains configuration for the activity processor. -type Config struct { - // NATS configuration - NATSURL string - NATSStreamName string // Source stream for audit events (e.g., "AUDIT_EVENTS") - ConsumerName string // Durable consumer name - - // Event stream configuration - NATSEventStream string // Source stream for Kubernetes events (e.g., "EVENTS") - NATSEventConsumer string // Durable consumer name for event processor - - // Output NATS stream for generated activities - OutputStreamName string // Stream for publishing activities (e.g., "ACTIVITIES") - OutputSubjectPrefix string // Subject prefix for activities (e.g., "activities") - - // NATS TLS/mTLS configuration - NATSTLSEnabled bool // Enable TLS for NATS connection - NATSTLSCertFile string // Path to client certificate file (for mTLS) - NATSTLSKeyFile string // Path to client private key file (for mTLS) - NATSTLSCAFile string // Path to CA certificate file for server verification - - // Dead-letter queue configuration - DLQEnabled bool // Enable dead-letter queue for failed events - DLQStreamName string // NATS stream name for DLQ (e.g., "ACTIVITY_DEAD_LETTER") - DLQSubjectPrefix string // Subject prefix for DLQ messages (e.g., "activity.dlq") - - // DLQ retry configuration - DLQRetryEnabled bool // Enable automatic retry of DLQ events - DLQRetryInterval time.Duration // Interval between retry batches - DLQRetryBatchSize int // Number of events to process per retry batch - DLQRetryBackoffBase time.Duration // Initial backoff duration - DLQRetryBackoffMultiplier float64 // Exponential backoff multiplier - DLQRetryBackoffMax time.Duration // Maximum backoff duration - DLQRetryAlertThreshold int // Retry count threshold for alerts - DLQRetryAuditSubject string // Subject for republishing audit events from DLQ - DLQRetryEventSubject string // Subject for republishing Kubernetes events from DLQ - - // Processing configuration - Workers int // Number of concurrent workers - BatchSize int // Messages to fetch per batch - AckWait time.Duration // Time before message redelivery - MaxDeliver int // Maximum redelivery attempts - - // Health probe configuration - HealthProbeAddr string // Address for health probe server (e.g., ":8081") - -} - -// DefaultConfig returns configuration with default values. -func DefaultConfig() Config { - return Config{ - NATSURL: "nats://localhost:4222", - NATSStreamName: "AUDIT_EVENTS", - ConsumerName: "activity-processor@activity.miloapis.com", - NATSEventStream: "EVENTS", - NATSEventConsumer: "activity-event-processor", - OutputStreamName: "ACTIVITIES", - OutputSubjectPrefix: "activities", - DLQEnabled: true, - DLQStreamName: "ACTIVITY_DEAD_LETTER", - DLQSubjectPrefix: "activity.dlq", - DLQRetryEnabled: true, - DLQRetryInterval: 5 * time.Minute, - DLQRetryBatchSize: 100, - DLQRetryBackoffBase: 1 * time.Minute, - DLQRetryBackoffMultiplier: 2.0, - DLQRetryBackoffMax: 24 * time.Hour, - DLQRetryAlertThreshold: 10, - Workers: 4, - BatchSize: 100, - AckWait: 30 * time.Second, - MaxDeliver: 5, - HealthProbeAddr: ":8081", - } -} - -// Processor consumes audit events from NATS, evaluates ActivityPolicies, -// and publishes Activity resources to NATS for downstream consumption. -type Processor struct { - config Config - restConfig *rest.Config - - nc *nats.Conn - js nats.JetStreamContext - - cache cache.Cache - - // mapper converts Kind to Resource using API discovery. Requires explicit - // Reset() on cache miss to discover newly registered CRDs. - mapper meta.ResettableRESTMapper - - // policyCache holds pre-compiled policies indexed by apiGroup/resource. - policyCache *PolicyCache - - // eventProcessor processes Kubernetes events from the EVENTS stream. - eventProcessor *processor.EventProcessor - - // eventEmitter emits Kubernetes Warning events for evaluation failures. - eventEmitter *EventEmitter - - // dlqPublisher publishes failed events to the dead-letter queue. - dlqPublisher processor.DLQPublisher - - // dlqRetryController handles automatic retry of DLQ events. - dlqRetryController *DLQRetryController - - wg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc - - // Health tracking - healthMu sync.RWMutex - ready bool // Cache synced and NATS connected - healthServer *http.Server -} - -// New creates a new activity processor. -func New(config Config, restConfig *rest.Config) (*Processor, error) { - ctx, cancel := context.WithCancel(context.Background()) - - p := &Processor{ - config: config, - restConfig: restConfig, - policyCache: NewPolicyCache(), - ctx: ctx, - cancel: cancel, - } - - return p, nil -} - -// Start begins processing audit events. -func (p *Processor) Start(ctx context.Context) error { - // Start health probe server early so Kubernetes can check liveness - if p.config.HealthProbeAddr != "" { - p.startHealthServer() - } - - discoveryClient, err := discovery.NewDiscoveryClientForConfig(p.restConfig) - if err != nil { - return fmt.Errorf("failed to create discovery client: %w", err) - } - cachedDiscoveryClient := memory.NewMemCacheClient(discoveryClient) - p.mapper = restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscoveryClient) - - c, err := cache.New(p.restConfig, cache.Options{ - Scheme: controller.Scheme, - }) - if err != nil { - return fmt.Errorf("failed to create cache: %w", err) - } - p.cache = c - - informer, err := p.cache.GetInformer(ctx, &v1alpha1.ActivityPolicy{}) - if err != nil { - return fmt.Errorf("failed to get informer for ActivityPolicy: %w", err) - } - - _, err = informer.AddEventHandler(toolscache.ResourceEventHandlerFuncs{ - AddFunc: p.onPolicyAdd, - UpdateFunc: p.onPolicyUpdate, - DeleteFunc: p.onPolicyDelete, - }) - if err != nil { - return fmt.Errorf("failed to add event handler: %w", err) - } - - p.wg.Add(1) - go func() { - defer p.wg.Done() - if err := p.cache.Start(ctx); err != nil { - klog.ErrorS(err, "Cache failed") - } - }() - - if !p.cache.WaitForCacheSync(ctx) { - return fmt.Errorf("failed to sync cache") - } - - klog.InfoS("ActivityPolicy cache synced") - - // Create controller-runtime client for event emission - k8sClient, err := client.New(p.restConfig, client.Options{ - Scheme: controller.Scheme, - }) - if err != nil { - return fmt.Errorf("failed to create kubernetes client: %w", err) - } - - // Create Kubernetes clientset for event broadcaster - clientset, err := kubernetes.NewForConfig(p.restConfig) - if err != nil { - return fmt.Errorf("failed to create kubernetes clientset: %w", err) - } - - // Create event broadcaster and recorder for emitting Kubernetes events - eventBroadcaster := record.NewBroadcaster() - eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{ - Interface: clientset.CoreV1().Events(""), - }) - recorder := eventBroadcaster.NewRecorder(controller.Scheme, corev1.EventSource{Component: "activity-processor"}) - - // Create event emitter for health reporting - p.eventEmitter = NewEventEmitter(k8sClient, recorder) - - // Build NATS connection options - natsOpts := []nats.Option{ - nats.Name("activity-processor"), - nats.RetryOnFailedConnect(true), - nats.MaxReconnects(-1), - nats.ReconnectWait(time.Second), - nats.ReconnectJitter(100*time.Millisecond, time.Second), - nats.DisconnectErrHandler(func(nc *nats.Conn, err error) { - natsConnectionStatus.Set(0) - natsDisconnectsTotal.Inc() - if err != nil { - klog.ErrorS(err, "NATS disconnected") - } else { - klog.Info("NATS disconnected") - } - }), - nats.ReconnectHandler(func(nc *nats.Conn) { - natsConnectionStatus.Set(1) - natsReconnectsTotal.Inc() - klog.InfoS("NATS reconnected", "url", nc.ConnectedUrl()) - }), - nats.ClosedHandler(func(nc *nats.Conn) { - natsConnectionStatus.Set(0) - klog.Info("NATS connection closed") - }), - nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) { - natsErrorsTotal.Inc() - subName := "" - if sub != nil { - subName = sub.Subject - } - klog.ErrorS(err, "NATS async error", "subject", subName) - }), - nats.LameDuckModeHandler(func(nc *nats.Conn) { - klog.InfoS("NATS server entering lame duck mode, will reconnect to another server") - }), - } - - // Add TLS configuration if enabled - if p.config.NATSTLSEnabled { - tlsConfig, err := p.buildNATSTLSConfig() - if err != nil { - return fmt.Errorf("failed to build NATS TLS config: %w", err) - } - natsOpts = append(natsOpts, nats.Secure(tlsConfig)) - klog.InfoS("NATS TLS enabled") - } - - nc, err := nats.Connect(p.config.NATSURL, natsOpts...) - if err != nil { - return fmt.Errorf("failed to connect to NATS: %w", err) - } - p.nc = nc - natsConnectionStatus.Set(1) - - js, err := nc.JetStream() - if err != nil { - nc.Close() - return fmt.Errorf("failed to create JetStream context: %w", err) - } - p.js = js - - // Streams and consumers are managed declaratively via NATS JetStream controller. - // Fail fast if they don't exist rather than attempting to create them. - _, err = js.ConsumerInfo(p.config.NATSStreamName, p.config.ConsumerName) - if err != nil { - nc.Close() - return fmt.Errorf("consumer %q not found on stream %q (ensure NATS JetStream resources are deployed): %w", - p.config.ConsumerName, p.config.NATSStreamName, err) - } - - _, err = js.StreamInfo(p.config.OutputStreamName) - if err != nil { - nc.Close() - return fmt.Errorf("output stream %q not found (ensure NATS JetStream resources are deployed): %w", - p.config.OutputStreamName, err) - } - - // Initialize dead-letter queue publisher - dlqConfig := processor.DLQConfig{ - Enabled: p.config.DLQEnabled, - StreamName: p.config.DLQStreamName, - SubjectPrefix: p.config.DLQSubjectPrefix, - } - if dlqConfig.Enabled { - // Verify DLQ stream exists - _, err = js.StreamInfo(dlqConfig.StreamName) - if err != nil { - klog.V(1).InfoS("DLQ stream not found, dead-letter queue will be disabled", - "stream", dlqConfig.StreamName, - "error", err, - ) - dlqConfig.Enabled = false - } - } - p.dlqPublisher = processor.NewDLQPublisher(js, dlqConfig) - if dlqConfig.Enabled { - klog.InfoS("Dead-letter queue enabled", - "stream", dlqConfig.StreamName, - "subjectPrefix", dlqConfig.SubjectPrefix, - ) - } - - // Initialize DLQ retry controller - if p.config.DLQRetryEnabled && dlqConfig.Enabled { - retryConfig := DLQRetryConfig{ - Enabled: p.config.DLQRetryEnabled, - Interval: p.config.DLQRetryInterval, - BatchSize: p.config.DLQRetryBatchSize, - BackoffBase: p.config.DLQRetryBackoffBase, - BackoffMultiplier: p.config.DLQRetryBackoffMultiplier, - BackoffMax: p.config.DLQRetryBackoffMax, - AlertThreshold: p.config.DLQRetryAlertThreshold, - AuditRetrySubject: p.config.DLQRetryAuditSubject, - EventRetrySubject: p.config.DLQRetryEventSubject, - } - p.dlqRetryController = NewDLQRetryController( - js, - retryConfig, - p.config.NATSStreamName, - p.config.NATSEventStream, - dlqConfig.StreamName, - dlqConfig.SubjectPrefix, - ) - - p.wg.Add(1) - go func() { - defer p.wg.Done() - if err := p.dlqRetryController.Start(ctx); err != nil { - klog.ErrorS(err, "DLQ retry controller failed") - } - }() - klog.InfoS("DLQ retry controller started", - "interval", retryConfig.Interval, - "batchSize", retryConfig.BatchSize, - ) - } - - klog.InfoS("Activity processor starting", - "stream", p.config.NATSStreamName, - "consumer", p.config.ConsumerName, - "outputStream", p.config.OutputStreamName, - "workers", p.config.Workers, - ) - - // Start audit event workers - workerErrors := make(chan error, p.config.Workers) - for i := 0; i < p.config.Workers; i++ { - p.wg.Add(1) - go p.worker(ctx, i, workerErrors) - } - go p.monitorWorkers(ctx, workerErrors) - - // Check that event stream consumer exists - _, err = js.ConsumerInfo(p.config.NATSEventStream, p.config.NATSEventConsumer) - if err != nil { - klog.V(1).InfoS("Event consumer not found, event processing will be disabled", - "stream", p.config.NATSEventStream, - "consumer", p.config.NATSEventConsumer, - "error", err, - ) - } else { - // Start event processor - p.eventProcessor = processor.NewEventProcessor( - p.js, - p.config.NATSEventStream, - p.config.NATSEventConsumer, - p.config.OutputSubjectPrefix, - p.policyCache, // PolicyCache implements EventPolicyLookup - p.config.Workers, - p.config.BatchSize, - p.dlqPublisher, - ) - p.wg.Add(1) - go func() { - defer p.wg.Done() - if err := p.eventProcessor.Run(ctx); err != nil { - klog.ErrorS(err, "Event processor failed") - } - }() - klog.InfoS("Event processor started", - "stream", p.config.NATSEventStream, - "consumer", p.config.NATSEventConsumer, - ) - } - - // Mark as ready and healthy now that everything is initialized - p.setReady(true) - - return nil -} - -// drainTimeout is the maximum time to wait for NATS connection to drain. -const drainTimeout = 30 * time.Second - -// Stop gracefully shuts down the processor. -func (p *Processor) Stop() { - klog.Info("Stopping activity processor") - - // Mark as unhealthy immediately - p.setReady(false) - - p.cancel() - p.wg.Wait() - - // Shutdown health server - if p.healthServer != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := p.healthServer.Shutdown(shutdownCtx); err != nil { - klog.ErrorS(err, "Failed to shutdown health server gracefully") - } - } - - // Drain NATS connection gracefully - if p.nc != nil && !p.nc.IsClosed() { - klog.Info("Draining NATS connection") - done := make(chan struct{}) - go func() { - if err := p.nc.Drain(); err != nil { - klog.ErrorS(err, "Failed to drain NATS connection, forcing close") - p.nc.Close() - } - close(done) - }() - - select { - case <-done: - klog.Info("NATS connection drained successfully") - case <-time.After(drainTimeout): - klog.Warning("NATS drain timed out, forcing close") - p.nc.Close() - } - } - klog.Info("Activity processor stopped") -} - -// monitorWorkers monitors worker health and logs errors. -func (p *Processor) monitorWorkers(ctx context.Context, workerErrors <-chan error) { - for { - select { - case <-ctx.Done(): - return - case err := <-workerErrors: - if err != nil { - klog.ErrorS(err, "Worker reported error") - } - } - } -} - -// kindToResource converts a Kind to its plural resource name using API discovery. -// On cache miss, it resets the discovery cache and retries to handle newly registered CRDs. -func (p *Processor) kindToResource(apiGroup, kind string) (string, error) { - gk := schema.GroupKind{ - Group: apiGroup, - Kind: kind, - } - - mapping, err := p.mapper.RESTMapping(gk) - if err != nil { - if meta.IsNoMatchError(err) { - // Cache miss - reset and retry to discover newly registered CRDs. - klog.V(2).InfoS("REST mapping not found, resetting discovery cache", - "apiGroup", apiGroup, - "kind", kind, - ) - p.mapper.Reset() - - mapping, err = p.mapper.RESTMapping(gk) - if err != nil { - return "", fmt.Errorf("failed to find resource mapping for %s/%s: %w", apiGroup, kind, err) - } - } else { - return "", fmt.Errorf("failed to find resource mapping for %s/%s: %w", apiGroup, kind, err) - } - } - - return mapping.Resource.Resource, nil -} - -// resourceToKind converts a plural resource name to its Kind using API discovery. -// On cache miss, it resets the discovery cache and retries to handle newly registered CRDs. -func (p *Processor) resourceToKind(apiGroup, resource string) (string, error) { - gvr := schema.GroupVersionResource{ - Group: apiGroup, - Resource: resource, - } - - kinds, err := p.mapper.KindsFor(gvr) - if err != nil { - if meta.IsNoMatchError(err) { - // Cache miss - reset and retry to discover newly registered CRDs. - klog.V(2).InfoS("Kind mapping not found, resetting discovery cache", - "apiGroup", apiGroup, - "resource", resource, - ) - p.mapper.Reset() - - kinds, err = p.mapper.KindsFor(gvr) - if err != nil { - return "", fmt.Errorf("failed to find kind for %s/%s: %w", apiGroup, resource, err) - } - } else { - return "", fmt.Errorf("failed to find kind for %s/%s: %w", apiGroup, resource, err) - } - } - - if len(kinds) == 0 { - return "", fmt.Errorf("no kind found for %s/%s", apiGroup, resource) - } - - return kinds[0].Kind, nil -} - -func (p *Processor) onPolicyAdd(obj any) { - policy, ok := obj.(*v1alpha1.ActivityPolicy) - if !ok { - klog.Error("Failed to cast object to ActivityPolicy in add handler") - return - } - - if !isPolicyReady(policy) { - klog.V(2).InfoS("Skipping policy that is not ready", - "policy", policy.Name, - ) - return - } - - // Convert Kind to resource (plural) to match audit event ObjectRef format. - resource, err := p.kindToResource(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) - if err != nil { - klog.ErrorS(err, "Failed to resolve resource for policy, skipping", - "policy", policy.Name, - "apiGroup", policy.Spec.Resource.APIGroup, - "kind", policy.Spec.Resource.Kind, - ) - return - } - - if err := p.policyCache.Add(policy, resource); err != nil { - klog.ErrorS(err, "Failed to compile and add policy", - "policy", policy.Name, - ) - return - } - - policyCount.Set(float64(p.policyCache.Len())) - - klog.InfoS("Added ActivityPolicy", - "policy", policy.Name, - "kind", policy.Spec.Resource.Kind, - "resource", resource, - ) -} - -func (p *Processor) onPolicyUpdate(oldObj, newObj any) { - oldPolicy, ok := oldObj.(*v1alpha1.ActivityPolicy) - if !ok { - klog.Error("Failed to cast old object to ActivityPolicy in update handler") - return - } - newPolicy, ok := newObj.(*v1alpha1.ActivityPolicy) - if !ok { - klog.Error("Failed to cast new object to ActivityPolicy in update handler") - return - } - - wasReady := isPolicyReady(oldPolicy) - isReady := isPolicyReady(newPolicy) - - oldResource, oldErr := p.kindToResource(oldPolicy.Spec.Resource.APIGroup, oldPolicy.Spec.Resource.Kind) - newResource, newErr := p.kindToResource(newPolicy.Spec.Resource.APIGroup, newPolicy.Spec.Resource.Kind) - - if oldErr != nil && wasReady { - klog.ErrorS(oldErr, "Failed to resolve old resource for policy update", - "policy", oldPolicy.Name, - "apiGroup", oldPolicy.Spec.Resource.APIGroup, - "kind", oldPolicy.Spec.Resource.Kind, - ) - } - if newErr != nil && isReady { - klog.ErrorS(newErr, "Failed to resolve new resource for policy update", - "policy", newPolicy.Name, - "apiGroup", newPolicy.Spec.Resource.APIGroup, - "kind", newPolicy.Spec.Resource.Kind, - ) - return - } - - // Remove old policy if it was ready - if wasReady && oldErr == nil { - p.policyCache.Remove(oldPolicy, oldResource) - } - - // Add new policy if it's ready - if isReady { - if err := p.policyCache.Add(newPolicy, newResource); err != nil { - klog.ErrorS(err, "Failed to compile and add updated policy", - "policy", newPolicy.Name, - ) - } else { - klog.InfoS("Updated ActivityPolicy", - "policy", newPolicy.Name, - "resource", policyKey(newPolicy.Spec.Resource.APIGroup, newResource), - "wasReady", wasReady, - "isReady", isReady, - ) - - // Trigger DLQ retry for events that failed on this policy - // Check context before spawning to avoid races during shutdown - if p.dlqRetryController != nil { - select { - case <-p.ctx.Done(): - // Processor is shutting down, don't start new retries - default: - go p.dlqRetryController.RetryForPolicy(p.ctx, newPolicy) - } - } - } - } else if wasReady { - klog.InfoS("ActivityPolicy no longer ready, removed from processing", - "policy", newPolicy.Name, - ) - } - - policyCount.Set(float64(p.policyCache.Len())) -} - -func (p *Processor) onPolicyDelete(obj any) { - policy, ok := obj.(*v1alpha1.ActivityPolicy) - if !ok { - // Informer may pass a tombstone when the object was deleted while disconnected. - tombstone, ok := obj.(toolscache.DeletedFinalStateUnknown) - if !ok { - klog.Error("Failed to cast object in delete handler") - return - } - policy, ok = tombstone.Obj.(*v1alpha1.ActivityPolicy) - if !ok { - klog.Error("Failed to cast tombstone object to ActivityPolicy") - return - } - } - - resource, err := p.kindToResource(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) - if err != nil { - klog.ErrorS(err, "Failed to resolve resource for policy delete", - "policy", policy.Name, - "apiGroup", policy.Spec.Resource.APIGroup, - "kind", policy.Spec.Resource.Kind, - ) - return - } - - p.policyCache.Remove(policy, resource) - policyCount.Set(float64(p.policyCache.Len())) - - klog.InfoS("Deleted ActivityPolicy", - "policy", policy.Name, - "resource", policyKey(policy.Spec.Resource.APIGroup, resource), - ) -} - -func (p *Processor) worker(ctx context.Context, id int, errors chan<- error) { - defer p.wg.Done() - defer workerCount.Dec() - - workerCount.Inc() - - sub, err := p.js.PullSubscribe( - "audit.k8s.>", - p.config.ConsumerName, - nats.Bind(p.config.NATSStreamName, p.config.ConsumerName), - ) - if err != nil { - klog.ErrorS(err, "Failed to create pull subscription", "worker", id) - errors <- fmt.Errorf("worker %d: failed to create subscription: %w", id, err) - return - } - defer sub.Unsubscribe() - - klog.V(2).InfoS("Worker started", "worker", id) - - for { - select { - case <-ctx.Done(): - klog.V(2).InfoS("Worker stopping", "worker", id) - return - default: - } - - msgs, err := sub.Fetch(p.config.BatchSize, nats.MaxWait(5*time.Second)) - if err != nil { - if err == nats.ErrTimeout { - continue - } - klog.ErrorS(err, "Failed to fetch messages", "worker", id) - continue - } - - for _, msg := range msgs { - if err := p.processMessage(msg); err != nil { - klog.ErrorS(err, "Failed to process message", "worker", id) - msg.Nak() - continue - } - msg.Ack() - } - } -} - -func (p *Processor) processMessage(msg *nats.Msg) error { - // Keep raw payload for DLQ in case of failure - rawPayload := json.RawMessage(msg.Data) - - var audit auditv1.Event - if err := json.Unmarshal(msg.Data, &audit); err != nil { - eventsErrored.WithLabelValues("audit_log", "unmarshal").Inc() - // Publish to DLQ - unmarshal errors are unrecoverable - // Tenant is nil since we couldn't unmarshal the event - if dlqErr := p.dlqPublisher.PublishAuditFailure( - p.ctx, rawPayload, "", 0, -1, processor.ErrorTypeUnmarshal, err, nil, nil, - ); dlqErr != nil { - klog.ErrorS(dlqErr, "Failed to publish to DLQ") - return fmt.Errorf("failed to unmarshal audit event: %w", err) - } - // Successfully published to DLQ, message can be ACKed - return nil - } - - // Extract tenant info early for DLQ context - activityTenant := processor.ExtractTenant(audit.User) - dlqTenant := processor.NewDeadLetterTenantFromActivity(activityTenant.Type, activityTenant.Name) - - if audit.ObjectRef == nil { - eventsSkipped.WithLabelValues("audit_log", "no_object_ref").Inc() - return nil - } - - apiGroup := audit.ObjectRef.APIGroup - if apiGroup == "" { - apiGroup = "core" - } - eventsReceived.WithLabelValues("audit_log", apiGroup, audit.ObjectRef.Resource).Inc() - - // Get compiled policies for this resource - policies := p.policyCache.Get(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource) - if len(policies) == 0 { - eventsSkipped.WithLabelValues("audit_log", "no_matching_policy").Inc() - return nil - } - - // Convert audit event to map for CEL evaluation - auditMap, err := auditToMap(&audit) - if err != nil { - eventsErrored.WithLabelValues("audit_log", "unmarshal").Inc() - // Publish to DLQ - auditToMap errors are unrecoverable - dlqResource := &processor.DeadLetterResource{ - APIGroup: audit.ObjectRef.APIGroup, - Kind: "", // Can't resolve kind without the map - Name: audit.ObjectRef.Name, - Namespace: audit.ObjectRef.Namespace, - } - // Try to resolve kind for better DLQ context - if kind, kindErr := p.resourceToKind(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource); kindErr == nil { - dlqResource.Kind = kind - } - if dlqErr := p.dlqPublisher.PublishAuditFailure( - p.ctx, rawPayload, "", 0, -1, processor.ErrorTypeUnmarshal, err, dlqResource, dlqTenant, - ); dlqErr != nil { - klog.ErrorS(dlqErr, "Failed to publish to DLQ") - return fmt.Errorf("failed to convert audit to map: %w", err) - } - return nil - } - - // Build resource info for DLQ context with proper kind resolution - dlqResource := &processor.DeadLetterResource{ - APIGroup: audit.ObjectRef.APIGroup, - Kind: "", // Will be set from policy or resolved dynamically - Name: audit.ObjectRef.Name, - Namespace: audit.ObjectRef.Namespace, - } - // Pre-resolve kind for DLQ context (best effort, will be overwritten by policy.Kind when available) - if kind, kindErr := p.resourceToKind(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource); kindErr == nil { - dlqResource.Kind = kind - } - - // First matching policy wins. - for _, policy := range policies { - policyStart := time.Now() - - // Use policy's Kind for DLQ context (more accurate than resolved kind) - dlqResource.Kind = policy.Kind - - // Evaluate audit rules using pre-compiled programs - activity, ruleIndex, err := p.evaluateCompiledAuditRules(policy, auditMap, &audit) - if err != nil { - // Serialize audit event for debugging - eventJSON, _ := json.Marshal(auditMap) - - klog.ErrorS(err, "Failed to evaluate policy", - "policy", policy.Name, - "auditID", audit.AuditID, - "eventJSON", truncateString(string(eventJSON), 4096), - ) - - // Emit Kubernetes Warning event - p.eventEmitter.EmitEvaluationError(p.ctx, policy.Name, ruleIndex, err) - - // Classify error type for DLQ using sentinel errors - errorType := processor.ErrorTypeCELMatch - if ruleIndex >= 0 { - // If we have a rule index, check if it's a kind resolution or summary error - if errors.Is(err, processor.ErrKindResolution) { - errorType = processor.ErrorTypeKindResolve - } else if errors.Is(err, processor.ErrActivityBuild) { - errorType = processor.ErrorTypeKindResolve // Activity build errors are typically kind resolution - } else { - errorType = processor.ErrorTypeCELSummary - } - } - - // Publish to DLQ - policyVersion := int64(0) - if policy.OriginalPolicy != nil { - policyVersion = policy.OriginalPolicy.Generation - } - if dlqErr := p.dlqPublisher.PublishAuditFailure( - p.ctx, rawPayload, policy.Name, policyVersion, ruleIndex, errorType, err, dlqResource, dlqTenant, - ); dlqErr != nil { - klog.ErrorS(dlqErr, "Failed to publish to DLQ, NAKing message") - eventsErrored.WithLabelValues("audit_log", "evaluate").Inc() - eventsEvaluated.WithLabelValues( - "audit_log", - policy.Name, - policy.APIGroup, - policy.Kind, - "error", - ).Inc() - eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) - // Return error to NAK the message so it gets redelivered - return fmt.Errorf("failed to evaluate policy and publish to DLQ: %w", dlqErr) - } - - // Successfully published to DLQ, continue to next policy (message will be ACKed) - eventsErrored.WithLabelValues("audit_log", "evaluate").Inc() - eventsEvaluated.WithLabelValues( - "audit_log", - policy.Name, - policy.APIGroup, - policy.Kind, - "error", - ).Inc() - eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) - continue - } - - ruleMatched := activity != nil - eventsEvaluated.WithLabelValues( - "audit_log", - policy.Name, - policy.APIGroup, - policy.Kind, - fmt.Sprintf("%t", ruleMatched), - ).Inc() - - if !ruleMatched { - eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) - continue - } - - if err := p.publishActivity(activity, policy); err != nil { - eventsErrored.WithLabelValues("audit_log", "publish").Inc() - eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) - return fmt.Errorf("failed to publish activity: %w", err) - } - - klog.V(4).InfoS("Generated activity", - "activity", activity.Name, - "policy", policy.Name, - "ruleIndex", ruleIndex, - "auditID", audit.AuditID, - ) - - eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) - return nil - } - - return nil -} - -// evaluateCompiledAuditRules evaluates audit rules using pre-compiled CEL programs. -func (p *Processor) evaluateCompiledAuditRules(policy *CompiledPolicy, auditMap map[string]any, audit *auditv1.Event) (*v1alpha1.Activity, int, error) { - for i := range policy.AuditRules { - rule := &policy.AuditRules[i] - if !rule.Valid { - continue - } - - matched, err := rule.EvaluateAuditMatch(auditMap) - if err != nil { - return nil, i, fmt.Errorf("rule %d match: %w", i, err) - } - - if matched { - // Use the cel package's EvaluateAuditSummaryMap which properly collects links - // from link() function calls in the summary template. - summary, links, err := cel.EvaluateAuditSummaryMap(rule.Summary, auditMap) - if err != nil { - return nil, i, fmt.Errorf("rule %d summary: %w", i, err) - } - - // Build activity using the processor package - builder := &processor.ActivityBuilder{ - APIGroup: policy.APIGroup, - Kind: policy.Kind, - } - activity, err := builder.BuildFromAudit(audit, summary, links, p.resourceToKind) - if err != nil { - return nil, i, fmt.Errorf("rule %d build: %w", i, err) - } - - return activity, i, nil - } - } - - return nil, -1, nil -} - -// auditToMap converts an audit event to a map for CEL evaluation. -func auditToMap(audit *auditv1.Event) (map[string]any, error) { - data, err := json.Marshal(audit) - if err != nil { - return nil, err - } - var m map[string]any - if err := json.Unmarshal(data, &m); err != nil { - return nil, err - } - return m, nil -} - -func (p *Processor) publishActivity(activity *v1alpha1.Activity, policy *CompiledPolicy) error { - data, err := json.Marshal(activity) - if err != nil { - return fmt.Errorf("failed to marshal activity: %w", err) - } - - subject := p.buildActivitySubject(activity) - - // Activity name is unique per audit event, enabling NATS deduplication. - publishStart := time.Now() - _, err = p.js.Publish(subject, data, nats.MsgId(activity.Name)) - natsPublishLatency.Observe(time.Since(publishStart).Seconds()) - if err != nil { - return fmt.Errorf("failed to publish to NATS: %w", err) - } - - natsMessagesPublished.Inc() - activitiesGenerated.WithLabelValues( - policy.Name, - policy.APIGroup, - policy.Kind, - ).Inc() - return nil -} - -// buildActivitySubject returns the NATS subject for routing activities. -// Format: ....... -func (p *Processor) buildActivitySubject(activity *v1alpha1.Activity) string { - prefix := p.config.OutputSubjectPrefix - - tenantType := activity.Spec.Tenant.Type - if tenantType == "" { - tenantType = "platform" - } - tenantName := activity.Spec.Tenant.Name - if tenantName == "" { - tenantName = "_" - } - - apiGroup := activity.Spec.Resource.APIGroup - if apiGroup == "" { - apiGroup = "core" - } - - origin := activity.Spec.Origin.Type - kind := activity.Spec.Resource.Kind - namespace := activity.Spec.Resource.Namespace - if namespace == "" { - namespace = "_" - } - name := activity.Name - - return fmt.Sprintf("%s.%s.%s.%s.%s.%s.%s.%s", - prefix, tenantType, tenantName, apiGroup, origin, kind, namespace, name) -} - -func policyKey(apiGroup, kindOrResource string) string { - return fmt.Sprintf("%s/%s", apiGroup, kindOrResource) -} - -func isPolicyReady(policy *v1alpha1.ActivityPolicy) bool { - return meta.IsStatusConditionTrue(policy.Status.Conditions, "Ready") -} - -// setReady sets the ready status. -func (p *Processor) setReady(ready bool) { - p.healthMu.Lock() - defer p.healthMu.Unlock() - p.ready = ready -} - -// isReady returns true if the processor is ready to serve traffic. -func (p *Processor) isReady() bool { - p.healthMu.RLock() - defer p.healthMu.RUnlock() - return p.ready -} - -// startHealthServer starts the HTTP health probe server using controller-runtime healthz. -func (p *Processor) startHealthServer() { - mux := http.NewServeMux() - - // Liveness probe - checks if the processor is alive and NATS is connected - mux.Handle("/healthz", http.StripPrefix("/healthz", &healthz.Handler{ - Checks: map[string]healthz.Checker{ - "ping": healthz.Ping, - "nats": p.natsHealthChecker(), - }, - })) - - // Readiness probe - checks if the processor is ready to receive traffic - mux.Handle("/readyz", http.StripPrefix("/readyz", &healthz.Handler{ - Checks: map[string]healthz.Checker{ - "ping": healthz.Ping, - "nats": p.natsHealthChecker(), - "cache-synced": p.cacheSyncedChecker(), - "policies-ready": p.policiesReadyChecker(), - }, - })) - - // Metrics endpoint for Prometheus scraping - mux.Handle("/metrics", promhttp.HandlerFor(metrics.Registry, promhttp.HandlerOpts{})) - - p.healthServer = &http.Server{ - Addr: p.config.HealthProbeAddr, - Handler: mux, - } - - p.wg.Add(1) - go func() { - defer p.wg.Done() - klog.InfoS("Starting health probe server", "addr", p.config.HealthProbeAddr) - if err := p.healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - klog.ErrorS(err, "Health probe server error") - } - }() -} - -// natsHealthChecker returns a health checker for NATS connection status. -func (p *Processor) natsHealthChecker() healthz.Checker { - return func(req *http.Request) error { - if p.nc == nil { - return fmt.Errorf("NATS connection not initialized") - } - if !p.nc.IsConnected() { - return fmt.Errorf("NATS connection is disconnected") - } - return nil - } -} - -// cacheSyncedChecker returns a health checker for cache sync status. -func (p *Processor) cacheSyncedChecker() healthz.Checker { - return func(req *http.Request) error { - if !p.isReady() { - return fmt.Errorf("cache not synced") - } - return nil - } -} - -// policiesReadyChecker returns a health checker that verifies policies are loaded. -func (p *Processor) policiesReadyChecker() healthz.Checker { - return func(req *http.Request) error { - // This is a soft check - we allow the processor to be ready even with no policies - // as policies may be added later. We just verify the cache is initialized. - if p.policyCache == nil { - return fmt.Errorf("policy cache not initialized") - } - return nil - } -} - -// buildNATSTLSConfig creates a TLS configuration for NATS connections. -func (p *Processor) buildNATSTLSConfig() (*tls.Config, error) { - tlsConfig := &tls.Config{ - MinVersion: tls.VersionTLS12, - } - - // Load client certificate and key if provided (for mTLS) - if p.config.NATSTLSCertFile != "" && p.config.NATSTLSKeyFile != "" { - cert, err := tls.LoadX509KeyPair(p.config.NATSTLSCertFile, p.config.NATSTLSKeyFile) - if err != nil { - return nil, fmt.Errorf("failed to load NATS client certificate: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - klog.V(2).InfoS("Loaded NATS client certificate", - "certFile", p.config.NATSTLSCertFile, - "keyFile", p.config.NATSTLSKeyFile, - ) - } - - // Load CA certificate if provided for server verification - if p.config.NATSTLSCAFile != "" { - caCert, err := os.ReadFile(p.config.NATSTLSCAFile) - if err != nil { - return nil, fmt.Errorf("failed to read NATS CA certificate: %w", err) - } - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse NATS CA certificate") - } - tlsConfig.RootCAs = caCertPool - klog.V(2).InfoS("Loaded NATS CA certificate", "caFile", p.config.NATSTLSCAFile) - } - - return tlsConfig, nil -} +package activityprocessor + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "sync" + "time" + + "github.com/nats-io/nats.go" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes" + typedcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + toolscache "k8s.io/client-go/tools/cache" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/metrics" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/internal/controller" + "go.miloapis.com/activity/internal/processor" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +var ( + eventsReceived = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Name: "events_received_total", + Help: "Total number of events received from NATS", + }, + []string{"source", "api_group", "resource"}, + ) + + eventsEvaluated = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Name: "events_evaluated_total", + Help: "Total number of events evaluated against policies", + }, + []string{"source", "policy", "api_group", "kind", "matched"}, + ) + + eventsSkipped = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Name: "events_skipped_total", + Help: "Total number of events skipped", + }, + []string{"source", "reason"}, + ) + + eventsErrored = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Name: "events_errored_total", + Help: "Total number of events that encountered errors", + }, + []string{"source", "error_type"}, + ) + + activitiesGenerated = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Name: "activities_generated_total", + Help: "Total number of activities generated and published", + }, + []string{"policy", "api_group", "kind"}, + ) + + eventProcessingDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: "activity_processor", + Name: "event_processing_duration_seconds", + Help: "Time spent processing events per policy", + Buckets: prometheus.DefBuckets, + }, + []string{"source", "policy"}, + ) + + policyCount = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "activity_processor", + Name: "active_policies", + Help: "Number of active (Ready) policies being used", + }, + ) + + workerCount = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "activity_processor", + Name: "active_workers", + Help: "Number of active worker goroutines", + }, + ) + + // NATS client metrics + natsConnectionStatus = prometheus.NewGauge( + prometheus.GaugeOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "connection_status", + Help: "NATS connection status (1 = connected, 0 = disconnected)", + }, + ) + + natsDisconnectsTotal = prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "disconnects_total", + Help: "Total number of NATS disconnection events", + }, + ) + + natsReconnectsTotal = prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "reconnects_total", + Help: "Total number of NATS reconnection events", + }, + ) + + natsErrorsTotal = prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "errors_total", + Help: "Total number of NATS async errors", + }, + ) + + natsMessagesPublished = prometheus.NewCounter( + prometheus.CounterOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "messages_published_total", + Help: "Total number of messages published to NATS", + }, + ) + + natsPublishLatency = prometheus.NewHistogram( + prometheus.HistogramOpts{ + Namespace: "activity_processor", + Subsystem: "nats", + Name: "publish_latency_seconds", + Help: "Latency of NATS publish operations", + Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1}, + }, + ) + +) + +func init() { + // Use controller-runtime's registry so metrics are exposed alongside controller metrics. + metrics.Registry.MustRegister( + eventsReceived, + eventsEvaluated, + eventsSkipped, + eventsErrored, + activitiesGenerated, + eventProcessingDuration, + policyCount, + workerCount, + // NATS metrics + natsConnectionStatus, + natsDisconnectsTotal, + natsReconnectsTotal, + natsErrorsTotal, + natsMessagesPublished, + natsPublishLatency, + ) +} + +// Config contains configuration for the activity processor. +type Config struct { + // NATS configuration + NATSURL string + NATSStreamName string // Source stream for audit events (e.g., "AUDIT_EVENTS") + ConsumerName string // Durable consumer name + + // Event stream configuration + NATSEventStream string // Source stream for Kubernetes events (e.g., "EVENTS") + NATSEventConsumer string // Durable consumer name for event processor + + // Output NATS stream for generated activities + OutputStreamName string // Stream for publishing activities (e.g., "ACTIVITIES") + OutputSubjectPrefix string // Subject prefix for activities (e.g., "activities") + + // NATS TLS/mTLS configuration + NATSTLSEnabled bool // Enable TLS for NATS connection + NATSTLSCertFile string // Path to client certificate file (for mTLS) + NATSTLSKeyFile string // Path to client private key file (for mTLS) + NATSTLSCAFile string // Path to CA certificate file for server verification + + // Dead-letter queue configuration + DLQEnabled bool // Enable dead-letter queue for failed events + DLQStreamName string // NATS stream name for DLQ (e.g., "ACTIVITY_DEAD_LETTER") + DLQSubjectPrefix string // Subject prefix for DLQ messages (e.g., "activity.dlq") + + // DLQ retry configuration + DLQRetryEnabled bool // Enable automatic retry of DLQ events + DLQRetryInterval time.Duration // Interval between retry batches + DLQRetryBatchSize int // Number of events to process per retry batch + DLQRetryBackoffBase time.Duration // Initial backoff duration + DLQRetryBackoffMultiplier float64 // Exponential backoff multiplier + DLQRetryBackoffMax time.Duration // Maximum backoff duration + DLQRetryAlertThreshold int // Retry count threshold for alerts + DLQRetryAuditSubject string // Subject for republishing audit events from DLQ + DLQRetryEventSubject string // Subject for republishing Kubernetes events from DLQ + + // Processing configuration + Workers int // Number of concurrent workers + BatchSize int // Messages to fetch per batch + AckWait time.Duration // Time before message redelivery + MaxDeliver int // Maximum redelivery attempts + + // Health probe configuration + HealthProbeAddr string // Address for health probe server (e.g., ":8081") + +} + +// DefaultConfig returns configuration with default values. +func DefaultConfig() Config { + return Config{ + NATSURL: "nats://localhost:4222", + NATSStreamName: "AUDIT_EVENTS", + ConsumerName: "activity-processor@activity.miloapis.com", + NATSEventStream: "EVENTS", + NATSEventConsumer: "activity-event-processor", + OutputStreamName: "ACTIVITIES", + OutputSubjectPrefix: "activities", + DLQEnabled: true, + DLQStreamName: "ACTIVITY_DEAD_LETTER", + DLQSubjectPrefix: "activity.dlq", + DLQRetryEnabled: true, + DLQRetryInterval: 5 * time.Minute, + DLQRetryBatchSize: 100, + DLQRetryBackoffBase: 1 * time.Minute, + DLQRetryBackoffMultiplier: 2.0, + DLQRetryBackoffMax: 24 * time.Hour, + DLQRetryAlertThreshold: 10, + Workers: 4, + BatchSize: 100, + AckWait: 30 * time.Second, + MaxDeliver: 5, + HealthProbeAddr: ":8081", + } +} + +// Processor consumes audit events from NATS, evaluates ActivityPolicies, +// and publishes Activity resources to NATS for downstream consumption. +type Processor struct { + config Config + restConfig *rest.Config + + nc *nats.Conn + js nats.JetStreamContext + + cache cache.Cache + + // mapper converts Kind to Resource using API discovery. Requires explicit + // Reset() on cache miss to discover newly registered CRDs. + mapper meta.ResettableRESTMapper + + // policyCache holds pre-compiled policies indexed by apiGroup/resource. + policyCache *PolicyCache + + // eventProcessor processes Kubernetes events from the EVENTS stream. + eventProcessor *processor.EventProcessor + + // eventEmitter emits Kubernetes Warning events for evaluation failures. + eventEmitter *EventEmitter + + // dlqPublisher publishes failed events to the dead-letter queue. + dlqPublisher processor.DLQPublisher + + // dlqRetryController handles automatic retry of DLQ events. + dlqRetryController *DLQRetryController + + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc + + // Health tracking + healthMu sync.RWMutex + ready bool // Cache synced and NATS connected + healthServer *http.Server +} + +// New creates a new activity processor. +func New(config Config, restConfig *rest.Config) (*Processor, error) { + ctx, cancel := context.WithCancel(context.Background()) + + p := &Processor{ + config: config, + restConfig: restConfig, + policyCache: NewPolicyCache(), + ctx: ctx, + cancel: cancel, + } + + return p, nil +} + +// Start begins processing audit events. +func (p *Processor) Start(ctx context.Context) error { + // Start health probe server early so Kubernetes can check liveness + if p.config.HealthProbeAddr != "" { + p.startHealthServer() + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(p.restConfig) + if err != nil { + return fmt.Errorf("failed to create discovery client: %w", err) + } + cachedDiscoveryClient := memory.NewMemCacheClient(discoveryClient) + p.mapper = restmapper.NewDeferredDiscoveryRESTMapper(cachedDiscoveryClient) + + c, err := cache.New(p.restConfig, cache.Options{ + Scheme: controller.Scheme, + }) + if err != nil { + return fmt.Errorf("failed to create cache: %w", err) + } + p.cache = c + + informer, err := p.cache.GetInformer(ctx, &v1alpha1.ActivityPolicy{}) + if err != nil { + return fmt.Errorf("failed to get informer for ActivityPolicy: %w", err) + } + + _, err = informer.AddEventHandler(toolscache.ResourceEventHandlerFuncs{ + AddFunc: p.onPolicyAdd, + UpdateFunc: p.onPolicyUpdate, + DeleteFunc: p.onPolicyDelete, + }) + if err != nil { + return fmt.Errorf("failed to add event handler: %w", err) + } + + p.wg.Add(1) + go func() { + defer p.wg.Done() + if err := p.cache.Start(ctx); err != nil { + klog.ErrorS(err, "Cache failed") + } + }() + + if !p.cache.WaitForCacheSync(ctx) { + return fmt.Errorf("failed to sync cache") + } + + klog.InfoS("ActivityPolicy cache synced") + + // Create controller-runtime client for event emission + k8sClient, err := client.New(p.restConfig, client.Options{ + Scheme: controller.Scheme, + }) + if err != nil { + return fmt.Errorf("failed to create kubernetes client: %w", err) + } + + // Create Kubernetes clientset for event broadcaster + clientset, err := kubernetes.NewForConfig(p.restConfig) + if err != nil { + return fmt.Errorf("failed to create kubernetes clientset: %w", err) + } + + // Create event broadcaster and recorder for emitting Kubernetes events + eventBroadcaster := record.NewBroadcaster() + eventBroadcaster.StartRecordingToSink(&typedcorev1.EventSinkImpl{ + Interface: clientset.CoreV1().Events(""), + }) + recorder := eventBroadcaster.NewRecorder(controller.Scheme, corev1.EventSource{Component: "activity-processor"}) + + // Create event emitter for health reporting + p.eventEmitter = NewEventEmitter(k8sClient, recorder) + + // Build NATS connection options + natsOpts := []nats.Option{ + nats.Name("activity-processor"), + nats.RetryOnFailedConnect(true), + nats.MaxReconnects(-1), + nats.ReconnectWait(time.Second), + nats.ReconnectJitter(100*time.Millisecond, time.Second), + nats.DisconnectErrHandler(func(nc *nats.Conn, err error) { + natsConnectionStatus.Set(0) + natsDisconnectsTotal.Inc() + if err != nil { + klog.ErrorS(err, "NATS disconnected") + } else { + klog.Info("NATS disconnected") + } + }), + nats.ReconnectHandler(func(nc *nats.Conn) { + natsConnectionStatus.Set(1) + natsReconnectsTotal.Inc() + klog.InfoS("NATS reconnected", "url", nc.ConnectedUrl()) + }), + nats.ClosedHandler(func(nc *nats.Conn) { + natsConnectionStatus.Set(0) + klog.Info("NATS connection closed") + }), + nats.ErrorHandler(func(nc *nats.Conn, sub *nats.Subscription, err error) { + natsErrorsTotal.Inc() + subName := "" + if sub != nil { + subName = sub.Subject + } + klog.ErrorS(err, "NATS async error", "subject", subName) + }), + nats.LameDuckModeHandler(func(nc *nats.Conn) { + klog.InfoS("NATS server entering lame duck mode, will reconnect to another server") + }), + } + + // Add TLS configuration if enabled + if p.config.NATSTLSEnabled { + tlsConfig, err := p.buildNATSTLSConfig() + if err != nil { + return fmt.Errorf("failed to build NATS TLS config: %w", err) + } + natsOpts = append(natsOpts, nats.Secure(tlsConfig)) + klog.InfoS("NATS TLS enabled") + } + + nc, err := nats.Connect(p.config.NATSURL, natsOpts...) + if err != nil { + return fmt.Errorf("failed to connect to NATS: %w", err) + } + p.nc = nc + natsConnectionStatus.Set(1) + + js, err := nc.JetStream() + if err != nil { + nc.Close() + return fmt.Errorf("failed to create JetStream context: %w", err) + } + p.js = js + + // Streams and consumers are managed declaratively via NATS JetStream controller. + // Fail fast if they don't exist rather than attempting to create them. + _, err = js.ConsumerInfo(p.config.NATSStreamName, p.config.ConsumerName) + if err != nil { + nc.Close() + return fmt.Errorf("consumer %q not found on stream %q (ensure NATS JetStream resources are deployed): %w", + p.config.ConsumerName, p.config.NATSStreamName, err) + } + + _, err = js.StreamInfo(p.config.OutputStreamName) + if err != nil { + nc.Close() + return fmt.Errorf("output stream %q not found (ensure NATS JetStream resources are deployed): %w", + p.config.OutputStreamName, err) + } + + // Initialize dead-letter queue publisher + dlqConfig := processor.DLQConfig{ + Enabled: p.config.DLQEnabled, + StreamName: p.config.DLQStreamName, + SubjectPrefix: p.config.DLQSubjectPrefix, + } + if dlqConfig.Enabled { + // Verify DLQ stream exists + _, err = js.StreamInfo(dlqConfig.StreamName) + if err != nil { + klog.V(1).InfoS("DLQ stream not found, dead-letter queue will be disabled", + "stream", dlqConfig.StreamName, + "error", err, + ) + dlqConfig.Enabled = false + } + } + p.dlqPublisher = processor.NewDLQPublisher(js, dlqConfig) + if dlqConfig.Enabled { + klog.InfoS("Dead-letter queue enabled", + "stream", dlqConfig.StreamName, + "subjectPrefix", dlqConfig.SubjectPrefix, + ) + } + + // Initialize DLQ retry controller + if p.config.DLQRetryEnabled && dlqConfig.Enabled { + retryConfig := DLQRetryConfig{ + Enabled: p.config.DLQRetryEnabled, + Interval: p.config.DLQRetryInterval, + BatchSize: p.config.DLQRetryBatchSize, + BackoffBase: p.config.DLQRetryBackoffBase, + BackoffMultiplier: p.config.DLQRetryBackoffMultiplier, + BackoffMax: p.config.DLQRetryBackoffMax, + AlertThreshold: p.config.DLQRetryAlertThreshold, + AuditRetrySubject: p.config.DLQRetryAuditSubject, + EventRetrySubject: p.config.DLQRetryEventSubject, + } + p.dlqRetryController = NewDLQRetryController( + js, + retryConfig, + p.config.NATSStreamName, + p.config.NATSEventStream, + dlqConfig.StreamName, + dlqConfig.SubjectPrefix, + ) + + p.wg.Add(1) + go func() { + defer p.wg.Done() + if err := p.dlqRetryController.Start(ctx); err != nil { + klog.ErrorS(err, "DLQ retry controller failed") + } + }() + klog.InfoS("DLQ retry controller started", + "interval", retryConfig.Interval, + "batchSize", retryConfig.BatchSize, + ) + } + + klog.InfoS("Activity processor starting", + "stream", p.config.NATSStreamName, + "consumer", p.config.ConsumerName, + "outputStream", p.config.OutputStreamName, + "workers", p.config.Workers, + ) + + // Start audit event workers + workerErrors := make(chan error, p.config.Workers) + for i := 0; i < p.config.Workers; i++ { + p.wg.Add(1) + go p.worker(ctx, i, workerErrors) + } + go p.monitorWorkers(ctx, workerErrors) + + // Check that event stream consumer exists + _, err = js.ConsumerInfo(p.config.NATSEventStream, p.config.NATSEventConsumer) + if err != nil { + klog.V(1).InfoS("Event consumer not found, event processing will be disabled", + "stream", p.config.NATSEventStream, + "consumer", p.config.NATSEventConsumer, + "error", err, + ) + } else { + // Start event processor + p.eventProcessor = processor.NewEventProcessor( + p.js, + p.config.NATSEventStream, + p.config.NATSEventConsumer, + p.config.OutputSubjectPrefix, + p.policyCache, // PolicyCache implements EventPolicyLookup + p.config.Workers, + p.config.BatchSize, + p.dlqPublisher, + ) + p.wg.Add(1) + go func() { + defer p.wg.Done() + if err := p.eventProcessor.Run(ctx); err != nil { + klog.ErrorS(err, "Event processor failed") + } + }() + klog.InfoS("Event processor started", + "stream", p.config.NATSEventStream, + "consumer", p.config.NATSEventConsumer, + ) + } + + // Mark as ready and healthy now that everything is initialized + p.setReady(true) + + return nil +} + +// drainTimeout is the maximum time to wait for NATS connection to drain. +const drainTimeout = 30 * time.Second + +// Stop gracefully shuts down the processor. +func (p *Processor) Stop() { + klog.Info("Stopping activity processor") + + // Mark as unhealthy immediately + p.setReady(false) + + p.cancel() + p.wg.Wait() + + // Shutdown health server + if p.healthServer != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := p.healthServer.Shutdown(shutdownCtx); err != nil { + klog.ErrorS(err, "Failed to shutdown health server gracefully") + } + } + + // Drain NATS connection gracefully + if p.nc != nil && !p.nc.IsClosed() { + klog.Info("Draining NATS connection") + done := make(chan struct{}) + go func() { + if err := p.nc.Drain(); err != nil { + klog.ErrorS(err, "Failed to drain NATS connection, forcing close") + p.nc.Close() + } + close(done) + }() + + select { + case <-done: + klog.Info("NATS connection drained successfully") + case <-time.After(drainTimeout): + klog.Warning("NATS drain timed out, forcing close") + p.nc.Close() + } + } + klog.Info("Activity processor stopped") +} + +// monitorWorkers monitors worker health and logs errors. +func (p *Processor) monitorWorkers(ctx context.Context, workerErrors <-chan error) { + for { + select { + case <-ctx.Done(): + return + case err := <-workerErrors: + if err != nil { + klog.ErrorS(err, "Worker reported error") + } + } + } +} + +// kindToResource converts a Kind to its plural resource name using API discovery. +// On cache miss, it resets the discovery cache and retries to handle newly registered CRDs. +func (p *Processor) kindToResource(apiGroup, kind string) (string, error) { + gk := schema.GroupKind{ + Group: apiGroup, + Kind: kind, + } + + mapping, err := p.mapper.RESTMapping(gk) + if err != nil { + if meta.IsNoMatchError(err) { + // Cache miss - reset and retry to discover newly registered CRDs. + klog.V(2).InfoS("REST mapping not found, resetting discovery cache", + "apiGroup", apiGroup, + "kind", kind, + ) + p.mapper.Reset() + + mapping, err = p.mapper.RESTMapping(gk) + if err != nil { + return "", fmt.Errorf("failed to find resource mapping for %s/%s: %w", apiGroup, kind, err) + } + } else { + return "", fmt.Errorf("failed to find resource mapping for %s/%s: %w", apiGroup, kind, err) + } + } + + return mapping.Resource.Resource, nil +} + +// resourceToKind converts a plural resource name to its Kind using API discovery. +// On cache miss, it resets the discovery cache and retries to handle newly registered CRDs. +func (p *Processor) resourceToKind(apiGroup, resource string) (string, error) { + gvr := schema.GroupVersionResource{ + Group: apiGroup, + Resource: resource, + } + + kinds, err := p.mapper.KindsFor(gvr) + if err != nil { + if meta.IsNoMatchError(err) { + // Cache miss - reset and retry to discover newly registered CRDs. + klog.V(2).InfoS("Kind mapping not found, resetting discovery cache", + "apiGroup", apiGroup, + "resource", resource, + ) + p.mapper.Reset() + + kinds, err = p.mapper.KindsFor(gvr) + if err != nil { + return "", fmt.Errorf("failed to find kind for %s/%s: %w", apiGroup, resource, err) + } + } else { + return "", fmt.Errorf("failed to find kind for %s/%s: %w", apiGroup, resource, err) + } + } + + if len(kinds) == 0 { + return "", fmt.Errorf("no kind found for %s/%s", apiGroup, resource) + } + + return kinds[0].Kind, nil +} + +func (p *Processor) onPolicyAdd(obj any) { + policy, ok := obj.(*v1alpha1.ActivityPolicy) + if !ok { + klog.Error("Failed to cast object to ActivityPolicy in add handler") + return + } + + if !isPolicyReady(policy) { + klog.V(2).InfoS("Skipping policy that is not ready", + "policy", policy.Name, + ) + return + } + + // Convert Kind to resource (plural) to match audit event ObjectRef format. + resource, err := p.kindToResource(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) + if err != nil { + klog.ErrorS(err, "Failed to resolve resource for policy, skipping", + "policy", policy.Name, + "apiGroup", policy.Spec.Resource.APIGroup, + "kind", policy.Spec.Resource.Kind, + ) + return + } + + if err := p.policyCache.Add(policy, resource); err != nil { + klog.ErrorS(err, "Failed to compile and add policy", + "policy", policy.Name, + ) + return + } + + policyCount.Set(float64(p.policyCache.Len())) + + klog.InfoS("Added ActivityPolicy", + "policy", policy.Name, + "kind", policy.Spec.Resource.Kind, + "resource", resource, + ) +} + +func (p *Processor) onPolicyUpdate(oldObj, newObj any) { + oldPolicy, ok := oldObj.(*v1alpha1.ActivityPolicy) + if !ok { + klog.Error("Failed to cast old object to ActivityPolicy in update handler") + return + } + newPolicy, ok := newObj.(*v1alpha1.ActivityPolicy) + if !ok { + klog.Error("Failed to cast new object to ActivityPolicy in update handler") + return + } + + wasReady := isPolicyReady(oldPolicy) + isReady := isPolicyReady(newPolicy) + + oldResource, oldErr := p.kindToResource(oldPolicy.Spec.Resource.APIGroup, oldPolicy.Spec.Resource.Kind) + newResource, newErr := p.kindToResource(newPolicy.Spec.Resource.APIGroup, newPolicy.Spec.Resource.Kind) + + if oldErr != nil && wasReady { + klog.ErrorS(oldErr, "Failed to resolve old resource for policy update", + "policy", oldPolicy.Name, + "apiGroup", oldPolicy.Spec.Resource.APIGroup, + "kind", oldPolicy.Spec.Resource.Kind, + ) + } + if newErr != nil && isReady { + klog.ErrorS(newErr, "Failed to resolve new resource for policy update", + "policy", newPolicy.Name, + "apiGroup", newPolicy.Spec.Resource.APIGroup, + "kind", newPolicy.Spec.Resource.Kind, + ) + return + } + + // Remove old policy if it was ready + if wasReady && oldErr == nil { + p.policyCache.Remove(oldPolicy, oldResource) + } + + // Add new policy if it's ready + if isReady { + if err := p.policyCache.Add(newPolicy, newResource); err != nil { + klog.ErrorS(err, "Failed to compile and add updated policy", + "policy", newPolicy.Name, + ) + } else { + klog.InfoS("Updated ActivityPolicy", + "policy", newPolicy.Name, + "resource", policyKey(newPolicy.Spec.Resource.APIGroup, newResource), + "wasReady", wasReady, + "isReady", isReady, + ) + + // Trigger DLQ retry for events that failed on this policy + // Check context before spawning to avoid races during shutdown + if p.dlqRetryController != nil { + select { + case <-p.ctx.Done(): + // Processor is shutting down, don't start new retries + default: + go p.dlqRetryController.RetryForPolicy(p.ctx, newPolicy) + } + } + } + } else if wasReady { + klog.InfoS("ActivityPolicy no longer ready, removed from processing", + "policy", newPolicy.Name, + ) + } + + policyCount.Set(float64(p.policyCache.Len())) +} + +func (p *Processor) onPolicyDelete(obj any) { + policy, ok := obj.(*v1alpha1.ActivityPolicy) + if !ok { + // Informer may pass a tombstone when the object was deleted while disconnected. + tombstone, ok := obj.(toolscache.DeletedFinalStateUnknown) + if !ok { + klog.Error("Failed to cast object in delete handler") + return + } + policy, ok = tombstone.Obj.(*v1alpha1.ActivityPolicy) + if !ok { + klog.Error("Failed to cast tombstone object to ActivityPolicy") + return + } + } + + resource, err := p.kindToResource(policy.Spec.Resource.APIGroup, policy.Spec.Resource.Kind) + if err != nil { + klog.ErrorS(err, "Failed to resolve resource for policy delete", + "policy", policy.Name, + "apiGroup", policy.Spec.Resource.APIGroup, + "kind", policy.Spec.Resource.Kind, + ) + return + } + + p.policyCache.Remove(policy, resource) + policyCount.Set(float64(p.policyCache.Len())) + + klog.InfoS("Deleted ActivityPolicy", + "policy", policy.Name, + "resource", policyKey(policy.Spec.Resource.APIGroup, resource), + ) +} + +func (p *Processor) worker(ctx context.Context, id int, errors chan<- error) { + defer p.wg.Done() + defer workerCount.Dec() + + workerCount.Inc() + + sub, err := p.js.PullSubscribe( + "audit.k8s.>", + p.config.ConsumerName, + nats.Bind(p.config.NATSStreamName, p.config.ConsumerName), + ) + if err != nil { + klog.ErrorS(err, "Failed to create pull subscription", "worker", id) + errors <- fmt.Errorf("worker %d: failed to create subscription: %w", id, err) + return + } + defer sub.Unsubscribe() + + klog.V(2).InfoS("Worker started", "worker", id) + + for { + select { + case <-ctx.Done(): + klog.V(2).InfoS("Worker stopping", "worker", id) + return + default: + } + + msgs, err := sub.Fetch(p.config.BatchSize, nats.MaxWait(5*time.Second)) + if err != nil { + if err == nats.ErrTimeout { + continue + } + klog.ErrorS(err, "Failed to fetch messages", "worker", id) + continue + } + + for _, msg := range msgs { + if err := p.processMessage(msg); err != nil { + klog.ErrorS(err, "Failed to process message", "worker", id) + msg.Nak() + continue + } + msg.Ack() + } + } +} + +func (p *Processor) processMessage(msg *nats.Msg) error { + // Keep raw payload for DLQ in case of failure + rawPayload := json.RawMessage(msg.Data) + + var audit auditv1.Event + if err := json.Unmarshal(msg.Data, &audit); err != nil { + eventsErrored.WithLabelValues("audit_log", "unmarshal").Inc() + // Publish to DLQ - unmarshal errors are unrecoverable + // Tenant is nil since we couldn't unmarshal the event + if dlqErr := p.dlqPublisher.PublishAuditFailure( + p.ctx, rawPayload, "", 0, -1, processor.ErrorTypeUnmarshal, err, nil, nil, + ); dlqErr != nil { + klog.ErrorS(dlqErr, "Failed to publish to DLQ") + return fmt.Errorf("failed to unmarshal audit event: %w", err) + } + // Successfully published to DLQ, message can be ACKed + return nil + } + + // Extract tenant info early for DLQ context + activityTenant := processor.ExtractTenant(audit.User) + dlqTenant := processor.NewDeadLetterTenantFromActivity(activityTenant.Type, activityTenant.Name) + + if audit.ObjectRef == nil { + eventsSkipped.WithLabelValues("audit_log", "no_object_ref").Inc() + return nil + } + + apiGroup := audit.ObjectRef.APIGroup + if apiGroup == "" { + apiGroup = "core" + } + eventsReceived.WithLabelValues("audit_log", apiGroup, audit.ObjectRef.Resource).Inc() + + // Get compiled policies for this resource + policies := p.policyCache.Get(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource) + if len(policies) == 0 { + eventsSkipped.WithLabelValues("audit_log", "no_matching_policy").Inc() + return nil + } + + // Convert audit event to map for CEL evaluation + auditMap, err := auditToMap(&audit) + if err != nil { + eventsErrored.WithLabelValues("audit_log", "unmarshal").Inc() + // Publish to DLQ - auditToMap errors are unrecoverable + dlqResource := &processor.DeadLetterResource{ + APIGroup: audit.ObjectRef.APIGroup, + Kind: "", // Can't resolve kind without the map + Name: audit.ObjectRef.Name, + Namespace: audit.ObjectRef.Namespace, + } + // Try to resolve kind for better DLQ context + if kind, kindErr := p.resourceToKind(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource); kindErr == nil { + dlqResource.Kind = kind + } + if dlqErr := p.dlqPublisher.PublishAuditFailure( + p.ctx, rawPayload, "", 0, -1, processor.ErrorTypeUnmarshal, err, dlqResource, dlqTenant, + ); dlqErr != nil { + klog.ErrorS(dlqErr, "Failed to publish to DLQ") + return fmt.Errorf("failed to convert audit to map: %w", err) + } + return nil + } + + // Build resource info for DLQ context with proper kind resolution + dlqResource := &processor.DeadLetterResource{ + APIGroup: audit.ObjectRef.APIGroup, + Kind: "", // Will be set from policy or resolved dynamically + Name: audit.ObjectRef.Name, + Namespace: audit.ObjectRef.Namespace, + } + // Pre-resolve kind for DLQ context (best effort, will be overwritten by policy.Kind when available) + if kind, kindErr := p.resourceToKind(audit.ObjectRef.APIGroup, audit.ObjectRef.Resource); kindErr == nil { + dlqResource.Kind = kind + } + + // First matching policy wins. + for _, policy := range policies { + policyStart := time.Now() + + // Use policy's Kind for DLQ context (more accurate than resolved kind) + dlqResource.Kind = policy.Kind + + // Evaluate audit rules using pre-compiled programs + activity, ruleIndex, err := p.evaluateCompiledAuditRules(policy, auditMap, &audit) + if err != nil { + // Serialize audit event for debugging + eventJSON, _ := json.Marshal(auditMap) + + klog.ErrorS(err, "Failed to evaluate policy", + "policy", policy.Name, + "auditID", audit.AuditID, + "eventJSON", truncateString(string(eventJSON), 4096), + ) + + // Emit Kubernetes Warning event + p.eventEmitter.EmitEvaluationError(p.ctx, policy.Name, ruleIndex, err) + + // Classify error type for DLQ using sentinel errors + errorType := processor.ErrorTypeCELMatch + if ruleIndex >= 0 { + // If we have a rule index, check if it's a kind resolution or summary error + if errors.Is(err, processor.ErrKindResolution) { + errorType = processor.ErrorTypeKindResolve + } else if errors.Is(err, processor.ErrActivityBuild) { + errorType = processor.ErrorTypeKindResolve // Activity build errors are typically kind resolution + } else { + errorType = processor.ErrorTypeCELSummary + } + } + + // Publish to DLQ + policyVersion := int64(0) + if policy.OriginalPolicy != nil { + policyVersion = policy.OriginalPolicy.Generation + } + if dlqErr := p.dlqPublisher.PublishAuditFailure( + p.ctx, rawPayload, policy.Name, policyVersion, ruleIndex, errorType, err, dlqResource, dlqTenant, + ); dlqErr != nil { + klog.ErrorS(dlqErr, "Failed to publish to DLQ, NAKing message") + eventsErrored.WithLabelValues("audit_log", "evaluate").Inc() + eventsEvaluated.WithLabelValues( + "audit_log", + policy.Name, + policy.APIGroup, + policy.Kind, + "error", + ).Inc() + eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) + // Return error to NAK the message so it gets redelivered + return fmt.Errorf("failed to evaluate policy and publish to DLQ: %w", dlqErr) + } + + // Successfully published to DLQ, continue to next policy (message will be ACKed) + eventsErrored.WithLabelValues("audit_log", "evaluate").Inc() + eventsEvaluated.WithLabelValues( + "audit_log", + policy.Name, + policy.APIGroup, + policy.Kind, + "error", + ).Inc() + eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) + continue + } + + ruleMatched := activity != nil + eventsEvaluated.WithLabelValues( + "audit_log", + policy.Name, + policy.APIGroup, + policy.Kind, + fmt.Sprintf("%t", ruleMatched), + ).Inc() + + if !ruleMatched { + eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) + continue + } + + if err := p.publishActivity(activity, policy); err != nil { + eventsErrored.WithLabelValues("audit_log", "publish").Inc() + eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) + return fmt.Errorf("failed to publish activity: %w", err) + } + + klog.V(4).InfoS("Generated activity", + "activity", activity.Name, + "policy", policy.Name, + "ruleIndex", ruleIndex, + "auditID", audit.AuditID, + ) + + eventProcessingDuration.WithLabelValues("audit_log", policy.Name).Observe(time.Since(policyStart).Seconds()) + return nil + } + + return nil +} + +// evaluateCompiledAuditRules evaluates audit rules using pre-compiled CEL programs. +func (p *Processor) evaluateCompiledAuditRules(policy *CompiledPolicy, auditMap map[string]any, audit *auditv1.Event) (*v1alpha1.Activity, int, error) { + for i := range policy.AuditRules { + rule := &policy.AuditRules[i] + if !rule.Valid { + continue + } + + matched, err := rule.EvaluateAuditMatch(auditMap) + if err != nil { + return nil, i, fmt.Errorf("rule %d match: %w", i, err) + } + + if matched { + // Use the cel package's EvaluateAuditSummaryMap which properly collects links + // from link() function calls in the summary template. + summary, links, err := cel.EvaluateAuditSummaryMap(rule.Summary, auditMap) + if err != nil { + return nil, i, fmt.Errorf("rule %d summary: %w", i, err) + } + + // Build activity using the processor package + builder := &processor.ActivityBuilder{ + APIGroup: policy.APIGroup, + Kind: policy.Kind, + } + activity, err := builder.BuildFromAudit(audit, summary, links, p.resourceToKind) + if err != nil { + return nil, i, fmt.Errorf("rule %d build: %w", i, err) + } + + return activity, i, nil + } + } + + return nil, -1, nil +} + +// auditToMap converts an audit event to a map for CEL evaluation. +func auditToMap(audit *auditv1.Event) (map[string]any, error) { + data, err := json.Marshal(audit) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil +} + +func (p *Processor) publishActivity(activity *v1alpha1.Activity, policy *CompiledPolicy) error { + data, err := json.Marshal(activity) + if err != nil { + return fmt.Errorf("failed to marshal activity: %w", err) + } + + subject := p.buildActivitySubject(activity) + + // Activity name is unique per audit event, enabling NATS deduplication. + publishStart := time.Now() + _, err = p.js.Publish(subject, data, nats.MsgId(activity.Name)) + natsPublishLatency.Observe(time.Since(publishStart).Seconds()) + if err != nil { + return fmt.Errorf("failed to publish to NATS: %w", err) + } + + natsMessagesPublished.Inc() + activitiesGenerated.WithLabelValues( + policy.Name, + policy.APIGroup, + policy.Kind, + ).Inc() + return nil +} + +// buildActivitySubject returns the NATS subject for routing activities. +// Format: ....... +func (p *Processor) buildActivitySubject(activity *v1alpha1.Activity) string { + prefix := p.config.OutputSubjectPrefix + + tenantType := activity.Spec.Tenant.Type + if tenantType == "" { + tenantType = "platform" + } + tenantName := activity.Spec.Tenant.Name + if tenantName == "" { + tenantName = "_" + } + + apiGroup := activity.Spec.Resource.APIGroup + if apiGroup == "" { + apiGroup = "core" + } + + origin := activity.Spec.Origin.Type + kind := activity.Spec.Resource.Kind + namespace := activity.Spec.Resource.Namespace + if namespace == "" { + namespace = "_" + } + name := activity.Name + + return fmt.Sprintf("%s.%s.%s.%s.%s.%s.%s.%s", + prefix, tenantType, tenantName, apiGroup, origin, kind, namespace, name) +} + +func policyKey(apiGroup, kindOrResource string) string { + return fmt.Sprintf("%s/%s", apiGroup, kindOrResource) +} + +func isPolicyReady(policy *v1alpha1.ActivityPolicy) bool { + return meta.IsStatusConditionTrue(policy.Status.Conditions, "Ready") +} + +// setReady sets the ready status. +func (p *Processor) setReady(ready bool) { + p.healthMu.Lock() + defer p.healthMu.Unlock() + p.ready = ready +} + +// isReady returns true if the processor is ready to serve traffic. +func (p *Processor) isReady() bool { + p.healthMu.RLock() + defer p.healthMu.RUnlock() + return p.ready +} + +// startHealthServer starts the HTTP health probe server using controller-runtime healthz. +func (p *Processor) startHealthServer() { + mux := http.NewServeMux() + + // Liveness probe - checks if the processor is alive and NATS is connected + mux.Handle("/healthz", http.StripPrefix("/healthz", &healthz.Handler{ + Checks: map[string]healthz.Checker{ + "ping": healthz.Ping, + "nats": p.natsHealthChecker(), + }, + })) + + // Readiness probe - checks if the processor is ready to receive traffic + mux.Handle("/readyz", http.StripPrefix("/readyz", &healthz.Handler{ + Checks: map[string]healthz.Checker{ + "ping": healthz.Ping, + "nats": p.natsHealthChecker(), + "cache-synced": p.cacheSyncedChecker(), + "policies-ready": p.policiesReadyChecker(), + }, + })) + + // Metrics endpoint for Prometheus scraping + mux.Handle("/metrics", promhttp.HandlerFor(metrics.Registry, promhttp.HandlerOpts{})) + + p.healthServer = &http.Server{ + Addr: p.config.HealthProbeAddr, + Handler: mux, + } + + p.wg.Add(1) + go func() { + defer p.wg.Done() + klog.InfoS("Starting health probe server", "addr", p.config.HealthProbeAddr) + if err := p.healthServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + klog.ErrorS(err, "Health probe server error") + } + }() +} + +// natsHealthChecker returns a health checker for NATS connection status. +func (p *Processor) natsHealthChecker() healthz.Checker { + return func(req *http.Request) error { + if p.nc == nil { + return fmt.Errorf("NATS connection not initialized") + } + if !p.nc.IsConnected() { + return fmt.Errorf("NATS connection is disconnected") + } + return nil + } +} + +// cacheSyncedChecker returns a health checker for cache sync status. +func (p *Processor) cacheSyncedChecker() healthz.Checker { + return func(req *http.Request) error { + if !p.isReady() { + return fmt.Errorf("cache not synced") + } + return nil + } +} + +// policiesReadyChecker returns a health checker that verifies policies are loaded. +func (p *Processor) policiesReadyChecker() healthz.Checker { + return func(req *http.Request) error { + // This is a soft check - we allow the processor to be ready even with no policies + // as policies may be added later. We just verify the cache is initialized. + if p.policyCache == nil { + return fmt.Errorf("policy cache not initialized") + } + return nil + } +} + +// buildNATSTLSConfig creates a TLS configuration for NATS connections. +func (p *Processor) buildNATSTLSConfig() (*tls.Config, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + // Load client certificate and key if provided (for mTLS) + if p.config.NATSTLSCertFile != "" && p.config.NATSTLSKeyFile != "" { + cert, err := tls.LoadX509KeyPair(p.config.NATSTLSCertFile, p.config.NATSTLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load NATS client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + klog.V(2).InfoS("Loaded NATS client certificate", + "certFile", p.config.NATSTLSCertFile, + "keyFile", p.config.NATSTLSKeyFile, + ) + } + + // Load CA certificate if provided for server verification + if p.config.NATSTLSCAFile != "" { + caCert, err := os.ReadFile(p.config.NATSTLSCAFile) + if err != nil { + return nil, fmt.Errorf("failed to read NATS CA certificate: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse NATS CA certificate") + } + tlsConfig.RootCAs = caCertPool + klog.V(2).InfoS("Loaded NATS CA certificate", "caFile", p.config.NATSTLSCAFile) + } + + return tlsConfig, nil +} diff --git a/internal/cel/environment.go b/internal/cel/environment.go index ab9f85ff..401939e3 100644 --- a/internal/cel/environment.go +++ b/internal/cel/environment.go @@ -1,211 +1,211 @@ -package cel - -import ( - "fmt" - "strings" - - "github.com/google/cel-go/cel" - "github.com/google/cel-go/common/types" - "github.com/google/cel-go/common/types/ref" -) - -// NewAuditEnvironment creates a CEL environment for audit rule expressions. -// Available variables: audit (map containing all audit fields), actor, actorRef, kind. -// Access audit fields via the audit map: audit.verb, audit.objectRef, audit.user, etc. -// If collector is non-nil, link() calls will capture link information. -func NewAuditEnvironment(collector *linkCollector) (*cel.Env, error) { - actorRefType := cel.MapType(cel.StringType, cel.DynType) - - return cel.NewEnv( - // All audit log fields are nested under the "audit" map variable. - // Access them as: audit.verb, audit.objectRef, audit.user, audit.responseStatus, - // audit.responseObject, audit.requestObject, etc. - cel.Variable("audit", cel.MapType(cel.StringType, cel.DynType)), - - // Convenience variables shared between audit and event contexts - cel.Variable("actor", cel.StringType), - cel.Variable("actorRef", actorRefType), - - // Also expose "kind" for convenience (extracted from audit.objectRef) - cel.Variable("kind", cel.StringType), - - // link function declaration with implementation: link(displayText string, resourceRef map) -> string - // Returns the display text and optionally captures link info in the collector. - cel.Function("link", - cel.Overload("link_string_dyn", - []*cel.Type{cel.StringType, cel.DynType}, - cel.StringType, - cel.BinaryBinding(func(displayText, resourceRef ref.Val) ref.Val { - text := fmt.Sprintf("%v", displayText.Value()) - if collector != nil { - collector.addLink(text, resourceRef.Value()) - } - return types.String(text) - }), - ), - ), - ) -} - -// NewEventEnvironment creates a CEL environment for event rule expressions. -// Available variables: event (full Kubernetes event), actor, actorRef -// If collector is non-nil, link() calls will capture link information. -func NewEventEnvironment(collector *linkCollector) (*cel.Env, error) { - // The event variable is a map containing the full Kubernetes Event - eventType := cel.MapType(cel.StringType, cel.DynType) - // The actorRef variable is a map with {type, name} for linking - actorRefType := cel.MapType(cel.StringType, cel.DynType) - - return cel.NewEnv( - cel.Variable("event", eventType), - cel.Variable("actor", cel.StringType), - cel.Variable("actorRef", actorRefType), - - // link function declaration with implementation: link(displayText string, resourceRef map) -> string - // Returns the display text and optionally captures link info in the collector. - cel.Function("link", - cel.Overload("link_string_dyn", - []*cel.Type{cel.StringType, cel.DynType}, - cel.StringType, - cel.BinaryBinding(func(displayText, resourceRef ref.Val) ref.Val { - text := fmt.Sprintf("%v", displayText.Value()) - if collector != nil { - collector.addLink(text, resourceRef.Value()) - } - return types.String(text) - }), - ), - ), - ) -} - -// BuildAuditVars creates the CEL variable map for audit evaluation. -// All audit log fields are nested under the "audit" key for consistency with -// event rules that use the "event" prefix (e.g., event.reason, event.type). -// Convenience variables actor, actorRef, and kind remain top-level. -// -// Nested fields that may be absent from the raw audit log (objectRef, user, -// responseStatus, responseObject, requestObject) are populated with empty maps -// when not present. This ensures that expressions using has() on nested fields -// (e.g. has(audit.objectRef.name)) evaluate safely instead of failing with a -// "no such key" error when the parent map is missing entirely. -func BuildAuditVars(auditMap map[string]interface{}) map[string]interface{} { - // Copy the original map so we don't mutate the caller's data. - auditWithDefaults := make(map[string]interface{}, len(auditMap)) - for k, v := range auditMap { - auditWithDefaults[k] = v - } - - // Ensure nested map fields exist so has() checks on their sub-fields don't - // fail at the parent level. - for _, field := range []string{"objectRef", "user", "responseStatus", "responseObject", "requestObject"} { - if _, ok := auditWithDefaults[field]; !ok { - auditWithDefaults[field] = map[string]interface{}{} - } - } - - vars := map[string]interface{}{ - "audit": auditWithDefaults, - "actor": ExtractString(auditMap, "user", "username"), - "actorRef": BuildActorRef(auditMap), - } - - // Extract kind for top-level convenience (from audit.objectRef.resource) - if objRef, ok := auditWithDefaults["objectRef"].(map[string]interface{}); ok { - if kind, ok := objRef["resource"].(string); ok { - vars["kind"] = kind - } else { - vars["kind"] = "" - } - } else { - vars["kind"] = "" - } - - return vars -} - -// BuildEventVars creates the CEL variable map for event evaluation. -func BuildEventVars(eventMap map[string]interface{}) map[string]interface{} { - return map[string]interface{}{ - "event": eventMap, - "actor": ExtractEventActor(eventMap), - "actorRef": BuildEventActorRef(eventMap), - } -} - -// ExtractString extracts a nested string value from a map. -func ExtractString(m map[string]interface{}, keys ...string) string { - current := m - for i, key := range keys { - if i == len(keys)-1 { - // Last key - expect string - if v, ok := current[key].(string); ok { - return v - } - return "" - } - // Not last key - expect nested map - if nested, ok := current[key].(map[string]interface{}); ok { - current = nested - } else { - return "" - } - } - return "" -} - -// ExtractMap extracts a nested map from a map using a sequence of keys. -// Returns an empty map if the path doesn't exist or the value is not a map. -func ExtractMap(m map[string]interface{}, keys ...string) map[string]interface{} { - current := m - for _, key := range keys { - if nested, ok := current[key].(map[string]interface{}); ok { - current = nested - } else { - return map[string]interface{}{} - } - } - return current -} - -// BuildActorRef builds an actor reference map from audit user info. -// Returns a map with {type, name} structure matching the Activity actor format. -func BuildActorRef(auditMap map[string]interface{}) map[string]interface{} { - username := ExtractString(auditMap, "user", "username") - if username == "" { - return map[string]interface{}{ - "type": "unknown", - "name": "", - } - } - - // Determine actor type based on username pattern - actorType := "user" - if strings.HasPrefix(username, "system:serviceaccount:") { - actorType = "serviceaccount" - } else if strings.HasPrefix(username, "system:") { - actorType = "system" - } - - return map[string]interface{}{ - "type": actorType, - "name": username, - } -} - -// ExtractEventActor extracts the actor name from a Kubernetes event. -func ExtractEventActor(eventMap map[string]interface{}) string { - if controller := ExtractString(eventMap, "reportingController"); controller != "" { - return controller - } - return ExtractString(eventMap, "source", "component") -} - -// BuildEventActorRef builds an actor reference map from a Kubernetes event. -func BuildEventActorRef(eventMap map[string]interface{}) map[string]interface{} { - controller := ExtractEventActor(eventMap) - return map[string]interface{}{ - "type": "controller", - "name": controller, - } -} +package cel + +import ( + "fmt" + "strings" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/common/types" + "github.com/google/cel-go/common/types/ref" +) + +// NewAuditEnvironment creates a CEL environment for audit rule expressions. +// Available variables: audit (map containing all audit fields), actor, actorRef, kind. +// Access audit fields via the audit map: audit.verb, audit.objectRef, audit.user, etc. +// If collector is non-nil, link() calls will capture link information. +func NewAuditEnvironment(collector *linkCollector) (*cel.Env, error) { + actorRefType := cel.MapType(cel.StringType, cel.DynType) + + return cel.NewEnv( + // All audit log fields are nested under the "audit" map variable. + // Access them as: audit.verb, audit.objectRef, audit.user, audit.responseStatus, + // audit.responseObject, audit.requestObject, etc. + cel.Variable("audit", cel.MapType(cel.StringType, cel.DynType)), + + // Convenience variables shared between audit and event contexts + cel.Variable("actor", cel.StringType), + cel.Variable("actorRef", actorRefType), + + // Also expose "kind" for convenience (extracted from audit.objectRef) + cel.Variable("kind", cel.StringType), + + // link function declaration with implementation: link(displayText string, resourceRef map) -> string + // Returns the display text and optionally captures link info in the collector. + cel.Function("link", + cel.Overload("link_string_dyn", + []*cel.Type{cel.StringType, cel.DynType}, + cel.StringType, + cel.BinaryBinding(func(displayText, resourceRef ref.Val) ref.Val { + text := fmt.Sprintf("%v", displayText.Value()) + if collector != nil { + collector.addLink(text, resourceRef.Value()) + } + return types.String(text) + }), + ), + ), + ) +} + +// NewEventEnvironment creates a CEL environment for event rule expressions. +// Available variables: event (full Kubernetes event), actor, actorRef +// If collector is non-nil, link() calls will capture link information. +func NewEventEnvironment(collector *linkCollector) (*cel.Env, error) { + // The event variable is a map containing the full Kubernetes Event + eventType := cel.MapType(cel.StringType, cel.DynType) + // The actorRef variable is a map with {type, name} for linking + actorRefType := cel.MapType(cel.StringType, cel.DynType) + + return cel.NewEnv( + cel.Variable("event", eventType), + cel.Variable("actor", cel.StringType), + cel.Variable("actorRef", actorRefType), + + // link function declaration with implementation: link(displayText string, resourceRef map) -> string + // Returns the display text and optionally captures link info in the collector. + cel.Function("link", + cel.Overload("link_string_dyn", + []*cel.Type{cel.StringType, cel.DynType}, + cel.StringType, + cel.BinaryBinding(func(displayText, resourceRef ref.Val) ref.Val { + text := fmt.Sprintf("%v", displayText.Value()) + if collector != nil { + collector.addLink(text, resourceRef.Value()) + } + return types.String(text) + }), + ), + ), + ) +} + +// BuildAuditVars creates the CEL variable map for audit evaluation. +// All audit log fields are nested under the "audit" key for consistency with +// event rules that use the "event" prefix (e.g., event.reason, event.type). +// Convenience variables actor, actorRef, and kind remain top-level. +// +// Nested fields that may be absent from the raw audit log (objectRef, user, +// responseStatus, responseObject, requestObject) are populated with empty maps +// when not present. This ensures that expressions using has() on nested fields +// (e.g. has(audit.objectRef.name)) evaluate safely instead of failing with a +// "no such key" error when the parent map is missing entirely. +func BuildAuditVars(auditMap map[string]interface{}) map[string]interface{} { + // Copy the original map so we don't mutate the caller's data. + auditWithDefaults := make(map[string]interface{}, len(auditMap)) + for k, v := range auditMap { + auditWithDefaults[k] = v + } + + // Ensure nested map fields exist so has() checks on their sub-fields don't + // fail at the parent level. + for _, field := range []string{"objectRef", "user", "responseStatus", "responseObject", "requestObject"} { + if _, ok := auditWithDefaults[field]; !ok { + auditWithDefaults[field] = map[string]interface{}{} + } + } + + vars := map[string]interface{}{ + "audit": auditWithDefaults, + "actor": ExtractString(auditMap, "user", "username"), + "actorRef": BuildActorRef(auditMap), + } + + // Extract kind for top-level convenience (from audit.objectRef.resource) + if objRef, ok := auditWithDefaults["objectRef"].(map[string]interface{}); ok { + if kind, ok := objRef["resource"].(string); ok { + vars["kind"] = kind + } else { + vars["kind"] = "" + } + } else { + vars["kind"] = "" + } + + return vars +} + +// BuildEventVars creates the CEL variable map for event evaluation. +func BuildEventVars(eventMap map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "event": eventMap, + "actor": ExtractEventActor(eventMap), + "actorRef": BuildEventActorRef(eventMap), + } +} + +// ExtractString extracts a nested string value from a map. +func ExtractString(m map[string]interface{}, keys ...string) string { + current := m + for i, key := range keys { + if i == len(keys)-1 { + // Last key - expect string + if v, ok := current[key].(string); ok { + return v + } + return "" + } + // Not last key - expect nested map + if nested, ok := current[key].(map[string]interface{}); ok { + current = nested + } else { + return "" + } + } + return "" +} + +// ExtractMap extracts a nested map from a map using a sequence of keys. +// Returns an empty map if the path doesn't exist or the value is not a map. +func ExtractMap(m map[string]interface{}, keys ...string) map[string]interface{} { + current := m + for _, key := range keys { + if nested, ok := current[key].(map[string]interface{}); ok { + current = nested + } else { + return map[string]interface{}{} + } + } + return current +} + +// BuildActorRef builds an actor reference map from audit user info. +// Returns a map with {type, name} structure matching the Activity actor format. +func BuildActorRef(auditMap map[string]interface{}) map[string]interface{} { + username := ExtractString(auditMap, "user", "username") + if username == "" { + return map[string]interface{}{ + "type": "unknown", + "name": "", + } + } + + // Determine actor type based on username pattern + actorType := "user" + if strings.HasPrefix(username, "system:serviceaccount:") { + actorType = "serviceaccount" + } else if strings.HasPrefix(username, "system:") { + actorType = "system" + } + + return map[string]interface{}{ + "type": actorType, + "name": username, + } +} + +// ExtractEventActor extracts the actor name from a Kubernetes event. +func ExtractEventActor(eventMap map[string]interface{}) string { + if controller := ExtractString(eventMap, "reportingController"); controller != "" { + return controller + } + return ExtractString(eventMap, "source", "component") +} + +// BuildEventActorRef builds an actor reference map from a Kubernetes event. +func BuildEventActorRef(eventMap map[string]interface{}) map[string]interface{} { + controller := ExtractEventActor(eventMap) + return map[string]interface{}{ + "type": "controller", + "name": controller, + } +} diff --git a/internal/controller/jobtemplate_test.go b/internal/controller/jobtemplate_test.go index 1bf34341..965f2d54 100644 --- a/internal/controller/jobtemplate_test.go +++ b/internal/controller/jobtemplate_test.go @@ -1,399 +1,399 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -func TestLoadJobTemplate(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, corev1.AddToScheme(scheme)) - - t.Run("loads valid template", func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-template", - Namespace: "activity-system", - }, - Data: map[string]string{ - "template.yaml": ` -spec: - serviceAccountName: custom-sa - volumes: - - name: kubeconfig - secret: - secretName: kubeconfig-secret - containers: - - name: reindex - args: - - --kubeconfig=/etc/kubernetes/kubeconfig - volumeMounts: - - name: kubeconfig - mountPath: /etc/kubernetes - readOnly: true -`, - }, - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cm). - Build() - - template, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") - require.NoError(t, err) - assert.Equal(t, "custom-sa", template.Spec.ServiceAccountName) - require.Len(t, template.Spec.Volumes, 1) - assert.Equal(t, "kubeconfig", template.Spec.Volumes[0].Name) - require.Len(t, template.Spec.Containers, 1) - assert.Equal(t, "reindex", template.Spec.Containers[0].Name) - }) - - t.Run("error when ConfigMap not found", func(t *testing.T) { - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() - - _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "nonexistent") - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get job template ConfigMap") - }) - - t.Run("error when template.yaml key missing", func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-template", - Namespace: "activity-system", - }, - Data: map[string]string{ - "other-key": "some data", - }, - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cm). - Build() - - _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") - require.Error(t, err) - assert.Contains(t, err.Error(), "does not contain key") - }) - - t.Run("error when template is invalid YAML", func(t *testing.T) { - cm := &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-template", - Namespace: "activity-system", - }, - Data: map[string]string{ - "template.yaml": "not: valid: yaml: [", - }, - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(cm). - Build() - - _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse job template") - }) -} - -func TestDefaultJobTemplate(t *testing.T) { - t.Parallel() - - template := DefaultJobTemplate() - - // Verify restart policy - assert.Equal(t, corev1.RestartPolicyOnFailure, template.Spec.RestartPolicy) - - // Verify security context - require.NotNil(t, template.Spec.SecurityContext) - assert.Equal(t, ptr.To(true), template.Spec.SecurityContext.RunAsNonRoot) - assert.Equal(t, ptr.To(int64(65532)), template.Spec.SecurityContext.RunAsUser) - - // Verify container - require.Len(t, template.Spec.Containers, 1) - assert.Equal(t, ReindexContainerName, template.Spec.Containers[0].Name) -} - -func TestMergeJobTemplate(t *testing.T) { - t.Parallel() - - t.Run("basic merge with default template", func(t *testing.T) { - template := DefaultJobTemplate() - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, - } - - job := MergeJobTemplate(template, opts) - - // Verify Job metadata - assert.Equal(t, "test-reindex-job", job.Name) - assert.Equal(t, "activity-system", job.Namespace) - assert.Equal(t, "activity-reindex", job.Labels["app"]) - assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) - - // Verify Job spec - assert.Equal(t, ptr.To(int32(3)), job.Spec.BackoffLimit) - assert.Equal(t, ptr.To(int32(300)), job.Spec.TTLSecondsAfterFinished) - - // Verify container - require.Len(t, job.Spec.Template.Spec.Containers, 1) - container := job.Spec.Template.Spec.Containers[0] - assert.Equal(t, "reindex", container.Name) - assert.Equal(t, "ghcr.io/datum-cloud/activity:test", container.Image) - assert.Equal(t, []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, container.Args) - }) - - t.Run("merge with template args prepended", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "reindex", - Args: []string{"--kubeconfig=/etc/kubernetes/kubeconfig"}, - }, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, - } - - job := MergeJobTemplate(template, opts) - - container := job.Spec.Template.Spec.Containers[0] - // Template args come first, controller args appended - expectedArgs := []string{ - "--kubeconfig=/etc/kubernetes/kubeconfig", - "reindex-worker", - "test-reindex", - "--nats-url=nats://localhost:4222", - } - assert.Equal(t, expectedArgs, container.Args) - }) - - t.Run("preserves template volumes and mounts", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Volumes: []corev1.Volume{ - { - Name: "kubeconfig", - VolumeSource: corev1.VolumeSource{ - CSI: &corev1.CSIVolumeSource{ - Driver: "cert-manager.io/csi", - }, - }, - }, - }, - Containers: []corev1.Container{ - { - Name: "reindex", - VolumeMounts: []corev1.VolumeMount{ - { - Name: "kubeconfig", - MountPath: "/etc/kubernetes", - ReadOnly: true, - }, - }, - }, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - } - - job := MergeJobTemplate(template, opts) - - // Verify volumes are preserved - require.Len(t, job.Spec.Template.Spec.Volumes, 1) - assert.Equal(t, "kubeconfig", job.Spec.Template.Spec.Volumes[0].Name) - assert.NotNil(t, job.Spec.Template.Spec.Volumes[0].CSI) - - // Verify volume mounts are preserved - container := job.Spec.Template.Spec.Containers[0] - require.Len(t, container.VolumeMounts, 1) - assert.Equal(t, "kubeconfig", container.VolumeMounts[0].Name) - assert.Equal(t, "/etc/kubernetes", container.VolumeMounts[0].MountPath) - }) - - t.Run("merges resource requirements", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - { - Name: "reindex", - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - }, - }, - }, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - ResourceRequirements: corev1.ResourceRequirements{ - Limits: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("2Gi"), - }, - Requests: corev1.ResourceList{ - corev1.ResourceMemory: resource.MustParse("2Gi"), - }, - }, - } - - job := MergeJobTemplate(template, opts) - - container := job.Spec.Template.Spec.Containers[0] - // Controller resources are merged (take precedence) - assert.Equal(t, resource.MustParse("2Gi"), container.Resources.Limits[corev1.ResourceMemory]) - assert.Equal(t, resource.MustParse("2Gi"), container.Resources.Requests[corev1.ResourceMemory]) - // Template CPU request is also present - assert.Equal(t, resource.MustParse("100m"), container.Resources.Requests[corev1.ResourceCPU]) - }) - - t.Run("sets service account from options", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - ServiceAccountName: "template-sa", - Containers: []corev1.Container{ - {Name: "reindex"}, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - ServiceAccountName: "controller-sa", - } - - job := MergeJobTemplate(template, opts) - - // Controller service account takes precedence - assert.Equal(t, "controller-sa", job.Spec.Template.Spec.ServiceAccountName) - }) - - t.Run("uses template service account when not set in options", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - ServiceAccountName: "template-sa", - Containers: []corev1.Container{ - {Name: "reindex"}, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - // No ServiceAccountName set - } - - job := MergeJobTemplate(template, opts) - - // Template service account is preserved - assert.Equal(t, "template-sa", job.Spec.Template.Spec.ServiceAccountName) - }) - - t.Run("creates reindex container if not in template", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "sidecar", Image: "sidecar:latest"}, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - } - - job := MergeJobTemplate(template, opts) - - // Should have both sidecar and reindex containers - require.Len(t, job.Spec.Template.Spec.Containers, 2) - - // Find reindex container - var reindexContainer *corev1.Container - for i := range job.Spec.Template.Spec.Containers { - if job.Spec.Template.Spec.Containers[i].Name == "reindex" { - reindexContainer = &job.Spec.Template.Spec.Containers[i] - break - } - } - require.NotNil(t, reindexContainer) - assert.Equal(t, "ghcr.io/datum-cloud/activity:test", reindexContainer.Image) - }) - - t.Run("merges labels from template", func(t *testing.T) { - template := &corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{ - "custom-label": "custom-value", - "app": "should-be-overridden", - }, - }, - Spec: corev1.PodSpec{ - Containers: []corev1.Container{ - {Name: "reindex"}, - }, - }, - } - - opts := JobBuildOptions{ - ReindexJobName: "test-reindex", - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ControllerArgs: []string{"reindex-worker", "test-reindex"}, - } - - job := MergeJobTemplate(template, opts) - - // Custom label is preserved - assert.Equal(t, "custom-value", job.Labels["custom-label"]) - // Controller-managed labels take precedence - assert.Equal(t, "activity-reindex", job.Labels["app"]) - assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) - }) -} +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestLoadJobTemplate(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + + t.Run("loads valid template", func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-template", + Namespace: "activity-system", + }, + Data: map[string]string{ + "template.yaml": ` +spec: + serviceAccountName: custom-sa + volumes: + - name: kubeconfig + secret: + secretName: kubeconfig-secret + containers: + - name: reindex + args: + - --kubeconfig=/etc/kubernetes/kubeconfig + volumeMounts: + - name: kubeconfig + mountPath: /etc/kubernetes + readOnly: true +`, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cm). + Build() + + template, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") + require.NoError(t, err) + assert.Equal(t, "custom-sa", template.Spec.ServiceAccountName) + require.Len(t, template.Spec.Volumes, 1) + assert.Equal(t, "kubeconfig", template.Spec.Volumes[0].Name) + require.Len(t, template.Spec.Containers, 1) + assert.Equal(t, "reindex", template.Spec.Containers[0].Name) + }) + + t.Run("error when ConfigMap not found", func(t *testing.T) { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "nonexistent") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to get job template ConfigMap") + }) + + t.Run("error when template.yaml key missing", func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-template", + Namespace: "activity-system", + }, + Data: map[string]string{ + "other-key": "some data", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cm). + Build() + + _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not contain key") + }) + + t.Run("error when template is invalid YAML", func(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-template", + Namespace: "activity-system", + }, + Data: map[string]string{ + "template.yaml": "not: valid: yaml: [", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cm). + Build() + + _, err := LoadJobTemplate(context.Background(), fakeClient, "activity-system", "test-template") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse job template") + }) +} + +func TestDefaultJobTemplate(t *testing.T) { + t.Parallel() + + template := DefaultJobTemplate() + + // Verify restart policy + assert.Equal(t, corev1.RestartPolicyOnFailure, template.Spec.RestartPolicy) + + // Verify security context + require.NotNil(t, template.Spec.SecurityContext) + assert.Equal(t, ptr.To(true), template.Spec.SecurityContext.RunAsNonRoot) + assert.Equal(t, ptr.To(int64(65532)), template.Spec.SecurityContext.RunAsUser) + + // Verify container + require.Len(t, template.Spec.Containers, 1) + assert.Equal(t, ReindexContainerName, template.Spec.Containers[0].Name) +} + +func TestMergeJobTemplate(t *testing.T) { + t.Parallel() + + t.Run("basic merge with default template", func(t *testing.T) { + template := DefaultJobTemplate() + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, + } + + job := MergeJobTemplate(template, opts) + + // Verify Job metadata + assert.Equal(t, "test-reindex-job", job.Name) + assert.Equal(t, "activity-system", job.Namespace) + assert.Equal(t, "activity-reindex", job.Labels["app"]) + assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) + + // Verify Job spec + assert.Equal(t, ptr.To(int32(3)), job.Spec.BackoffLimit) + assert.Equal(t, ptr.To(int32(300)), job.Spec.TTLSecondsAfterFinished) + + // Verify container + require.Len(t, job.Spec.Template.Spec.Containers, 1) + container := job.Spec.Template.Spec.Containers[0] + assert.Equal(t, "reindex", container.Name) + assert.Equal(t, "ghcr.io/datum-cloud/activity:test", container.Image) + assert.Equal(t, []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, container.Args) + }) + + t.Run("merge with template args prepended", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "reindex", + Args: []string{"--kubeconfig=/etc/kubernetes/kubeconfig"}, + }, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex", "--nats-url=nats://localhost:4222"}, + } + + job := MergeJobTemplate(template, opts) + + container := job.Spec.Template.Spec.Containers[0] + // Template args come first, controller args appended + expectedArgs := []string{ + "--kubeconfig=/etc/kubernetes/kubeconfig", + "reindex-worker", + "test-reindex", + "--nats-url=nats://localhost:4222", + } + assert.Equal(t, expectedArgs, container.Args) + }) + + t.Run("preserves template volumes and mounts", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Volumes: []corev1.Volume{ + { + Name: "kubeconfig", + VolumeSource: corev1.VolumeSource{ + CSI: &corev1.CSIVolumeSource{ + Driver: "cert-manager.io/csi", + }, + }, + }, + }, + Containers: []corev1.Container{ + { + Name: "reindex", + VolumeMounts: []corev1.VolumeMount{ + { + Name: "kubeconfig", + MountPath: "/etc/kubernetes", + ReadOnly: true, + }, + }, + }, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + } + + job := MergeJobTemplate(template, opts) + + // Verify volumes are preserved + require.Len(t, job.Spec.Template.Spec.Volumes, 1) + assert.Equal(t, "kubeconfig", job.Spec.Template.Spec.Volumes[0].Name) + assert.NotNil(t, job.Spec.Template.Spec.Volumes[0].CSI) + + // Verify volume mounts are preserved + container := job.Spec.Template.Spec.Containers[0] + require.Len(t, container.VolumeMounts, 1) + assert.Equal(t, "kubeconfig", container.VolumeMounts[0].Name) + assert.Equal(t, "/etc/kubernetes", container.VolumeMounts[0].MountPath) + }) + + t.Run("merges resource requirements", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "reindex", + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + }, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + ResourceRequirements: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("2Gi"), + }, + }, + } + + job := MergeJobTemplate(template, opts) + + container := job.Spec.Template.Spec.Containers[0] + // Controller resources are merged (take precedence) + assert.Equal(t, resource.MustParse("2Gi"), container.Resources.Limits[corev1.ResourceMemory]) + assert.Equal(t, resource.MustParse("2Gi"), container.Resources.Requests[corev1.ResourceMemory]) + // Template CPU request is also present + assert.Equal(t, resource.MustParse("100m"), container.Resources.Requests[corev1.ResourceCPU]) + }) + + t.Run("sets service account from options", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: "template-sa", + Containers: []corev1.Container{ + {Name: "reindex"}, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + ServiceAccountName: "controller-sa", + } + + job := MergeJobTemplate(template, opts) + + // Controller service account takes precedence + assert.Equal(t, "controller-sa", job.Spec.Template.Spec.ServiceAccountName) + }) + + t.Run("uses template service account when not set in options", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + ServiceAccountName: "template-sa", + Containers: []corev1.Container{ + {Name: "reindex"}, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + // No ServiceAccountName set + } + + job := MergeJobTemplate(template, opts) + + // Template service account is preserved + assert.Equal(t, "template-sa", job.Spec.Template.Spec.ServiceAccountName) + }) + + t.Run("creates reindex container if not in template", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "sidecar", Image: "sidecar:latest"}, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + } + + job := MergeJobTemplate(template, opts) + + // Should have both sidecar and reindex containers + require.Len(t, job.Spec.Template.Spec.Containers, 2) + + // Find reindex container + var reindexContainer *corev1.Container + for i := range job.Spec.Template.Spec.Containers { + if job.Spec.Template.Spec.Containers[i].Name == "reindex" { + reindexContainer = &job.Spec.Template.Spec.Containers[i] + break + } + } + require.NotNil(t, reindexContainer) + assert.Equal(t, "ghcr.io/datum-cloud/activity:test", reindexContainer.Image) + }) + + t.Run("merges labels from template", func(t *testing.T) { + template := &corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Labels: map[string]string{ + "custom-label": "custom-value", + "app": "should-be-overridden", + }, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + {Name: "reindex"}, + }, + }, + } + + opts := JobBuildOptions{ + ReindexJobName: "test-reindex", + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ControllerArgs: []string{"reindex-worker", "test-reindex"}, + } + + job := MergeJobTemplate(template, opts) + + // Custom label is preserved + assert.Equal(t, "custom-value", job.Labels["custom-label"]) + // Controller-managed labels take precedence + assert.Equal(t, "activity-reindex", job.Labels["app"]) + assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) + }) +} diff --git a/internal/controller/reindexjob_controller_test.go b/internal/controller/reindexjob_controller_test.go index 826402bf..cd9032d7 100644 --- a/internal/controller/reindexjob_controller_test.go +++ b/internal/controller/reindexjob_controller_test.go @@ -1,351 +1,351 @@ -package controller - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - batchv1 "k8s.io/api/batch/v1" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/tools/record" - "k8s.io/utils/ptr" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -func TestBuildJobForReindexJob(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, v1alpha1.AddToScheme(scheme)) - require.NoError(t, batchv1.AddToScheme(scheme)) - - reconciler := &ReindexJobReconciler{ - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ReindexServiceAccount: "activity-reindex-worker", - ReindexMemoryLimit: "2Gi", - ReindexCPULimit: "1000m", - NATSURL: "nats://nats.activity-system.svc:4222", - NATSTLSEnabled: false, - } - - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex", - }, - Spec: v1alpha1.ReindexJobSpec{ - TimeRange: v1alpha1.ReindexTimeRange{ - StartTime: "now-7d", - EndTime: "now", - }, - }, - } - - job, err := reconciler.buildJobForReindexJob(reindexJob) - require.NoError(t, err) - - // Verify Job metadata - assert.Equal(t, "test-reindex-job", job.Name) - assert.Equal(t, "activity-system", job.Namespace) - assert.Equal(t, "activity-reindex", job.Labels["app"]) - assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) - - // Verify Job spec - assert.Equal(t, ptr.To(int32(3)), job.Spec.BackoffLimit) - assert.Equal(t, ptr.To(int32(300)), job.Spec.TTLSecondsAfterFinished) - assert.Equal(t, corev1.RestartPolicyOnFailure, job.Spec.Template.Spec.RestartPolicy) - assert.Equal(t, "activity-reindex-worker", job.Spec.Template.Spec.ServiceAccountName) - - // Verify container - require.Len(t, job.Spec.Template.Spec.Containers, 1) - container := job.Spec.Template.Spec.Containers[0] - assert.Equal(t, "reindex", container.Name) - assert.Equal(t, "ghcr.io/datum-cloud/activity:test", container.Image) - - // Verify args - expectedArgs := []string{ - "reindex-worker", - "test-reindex", - "--nats-url=nats://nats.activity-system.svc:4222", - "--logging-format=json", - } - assert.Equal(t, expectedArgs, container.Args) - - // Verify resource limits - memLimit := resource.MustParse("2Gi") - cpuLimit := resource.MustParse("1000m") - assert.Equal(t, memLimit, container.Resources.Limits[corev1.ResourceMemory]) - assert.Equal(t, memLimit, container.Resources.Requests[corev1.ResourceMemory]) - assert.Equal(t, cpuLimit, container.Resources.Limits[corev1.ResourceCPU]) - - // Verify Pod SecurityContext - require.NotNil(t, job.Spec.Template.Spec.SecurityContext) - podSecurityContext := job.Spec.Template.Spec.SecurityContext - assert.Equal(t, ptr.To(true), podSecurityContext.RunAsNonRoot) - assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.RunAsUser) - assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.RunAsGroup) - assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.FSGroup) - require.NotNil(t, podSecurityContext.SeccompProfile) - assert.Equal(t, corev1.SeccompProfileTypeRuntimeDefault, podSecurityContext.SeccompProfile.Type) - - // Verify Container SecurityContext - require.NotNil(t, container.SecurityContext) - containerSecurityContext := container.SecurityContext - assert.Equal(t, ptr.To(false), containerSecurityContext.AllowPrivilegeEscalation) - assert.Equal(t, ptr.To(true), containerSecurityContext.ReadOnlyRootFilesystem) - assert.Equal(t, ptr.To(true), containerSecurityContext.RunAsNonRoot) - require.NotNil(t, containerSecurityContext.Capabilities) - assert.Equal(t, []corev1.Capability{"ALL"}, containerSecurityContext.Capabilities.Drop) -} - -func TestBuildJobForReindexJob_WithTLS(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, v1alpha1.AddToScheme(scheme)) - require.NoError(t, batchv1.AddToScheme(scheme)) - - reconciler := &ReindexJobReconciler{ - JobNamespace: "activity-system", - ActivityImage: "ghcr.io/datum-cloud/activity:test", - ReindexServiceAccount: "activity-reindex-worker", - ReindexMemoryLimit: "2Gi", - ReindexCPULimit: "1000m", - NATSURL: "nats://nats.activity-system.svc:4222", - NATSTLSEnabled: true, - NATSTLSCertFile: "/certs/tls.crt", - NATSTLSKeyFile: "/certs/tls.key", - NATSTLSCAFile: "/certs/ca.crt", - } - - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex-tls", - }, - Spec: v1alpha1.ReindexJobSpec{ - TimeRange: v1alpha1.ReindexTimeRange{ - StartTime: "now-7d", - }, - }, - } - - job, err := reconciler.buildJobForReindexJob(reindexJob) - require.NoError(t, err) - - // Verify TLS args are included - container := job.Spec.Template.Spec.Containers[0] - expectedArgs := []string{ - "reindex-worker", - "test-reindex-tls", - "--nats-url=nats://nats.activity-system.svc:4222", - "--logging-format=json", - "--nats-tls-enabled=true", - "--nats-tls-cert-file=/certs/tls.crt", - "--nats-tls-key-file=/certs/tls.key", - "--nats-tls-ca-file=/certs/ca.crt", - } - assert.Equal(t, expectedArgs, container.Args) -} - -func TestCountRunningJobs(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, v1alpha1.AddToScheme(scheme)) - require.NoError(t, batchv1.AddToScheme(scheme)) - - now := metav1.Now() - - // Create test jobs - runningJob1 := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "job-1", - Namespace: "activity-system", - Labels: map[string]string{ - "app": "activity-reindex", - }, - }, - Status: batchv1.JobStatus{ - // No completion time - still running - }, - } - - runningJob2 := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "job-2", - Namespace: "activity-system", - Labels: map[string]string{ - "app": "activity-reindex", - }, - }, - Status: batchv1.JobStatus{ - // No completion time - still running - }, - } - - completedJob := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "job-3", - Namespace: "activity-system", - Labels: map[string]string{ - "app": "activity-reindex", - }, - }, - Status: batchv1.JobStatus{ - CompletionTime: &now, - }, - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(runningJob1, runningJob2, completedJob). - Build() - - reconciler := &ReindexJobReconciler{ - Client: fakeClient, - JobClient: fakeClient, - JobNamespace: "activity-system", - } - - count, err := reconciler.countRunningJobs(context.Background()) - require.NoError(t, err) - assert.Equal(t, 2, count, "should count only running jobs (without completion time)") -} - -func TestGetJobForReindexJob(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, v1alpha1.AddToScheme(scheme)) - require.NoError(t, batchv1.AddToScheme(scheme)) - - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex-job", - Namespace: "activity-system", - }, - } - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(job). - Build() - - reconciler := &ReindexJobReconciler{ - Client: fakeClient, - JobClient: fakeClient, - JobNamespace: "activity-system", - } - - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex", - }, - } - - foundJob, err := reconciler.getJobForReindexJob(context.Background(), reindexJob) - require.NoError(t, err) - assert.Equal(t, "test-reindex-job", foundJob.Name) -} - -func TestCheckJobStatus(t *testing.T) { - t.Parallel() - - scheme := runtime.NewScheme() - require.NoError(t, v1alpha1.AddToScheme(scheme)) - require.NoError(t, batchv1.AddToScheme(scheme)) - - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - Build() - - recorder := record.NewFakeRecorder(10) - - reconciler := &ReindexJobReconciler{ - Client: fakeClient, - JobClient: fakeClient, - Recorder: recorder, - } - - t.Run("job still running", func(t *testing.T) { - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex", - }, - Status: v1alpha1.ReindexJobStatus{ - Phase: v1alpha1.ReindexJobRunning, - }, - } - - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex-job", - }, - Status: batchv1.JobStatus{ - // No completion time - still running - }, - } - - result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) - require.NoError(t, err) - assert.Greater(t, result.RequeueAfter.Seconds(), float64(0), "should requeue to check again later") - }) - - t.Run("job completed successfully", func(t *testing.T) { - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex", - }, - Status: v1alpha1.ReindexJobStatus{ - Phase: v1alpha1.ReindexJobRunning, - }, - } - - now := metav1.Now() - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex-job", - }, - Status: batchv1.JobStatus{ - Succeeded: 1, - CompletionTime: &now, - }, - } - - result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) - require.NoError(t, err) - assert.Equal(t, int64(0), result.RequeueAfter.Nanoseconds(), "should not requeue for completed job") - }) - - t.Run("job failed", func(t *testing.T) { - reindexJob := &v1alpha1.ReindexJob{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex", - }, - Status: v1alpha1.ReindexJobStatus{ - Phase: v1alpha1.ReindexJobRunning, - }, - } - - now := metav1.Now() - job := &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-reindex-job", - }, - Status: batchv1.JobStatus{ - Failed: 1, - CompletionTime: &now, - }, - } - - result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) - require.NoError(t, err) - assert.Equal(t, int64(0), result.RequeueAfter.Nanoseconds(), "should not requeue for completed job") - }) -} +package controller + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +func TestBuildJobForReindexJob(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + + reconciler := &ReindexJobReconciler{ + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ReindexServiceAccount: "activity-reindex-worker", + ReindexMemoryLimit: "2Gi", + ReindexCPULimit: "1000m", + NATSURL: "nats://nats.activity-system.svc:4222", + NATSTLSEnabled: false, + } + + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex", + }, + Spec: v1alpha1.ReindexJobSpec{ + TimeRange: v1alpha1.ReindexTimeRange{ + StartTime: "now-7d", + EndTime: "now", + }, + }, + } + + job, err := reconciler.buildJobForReindexJob(reindexJob) + require.NoError(t, err) + + // Verify Job metadata + assert.Equal(t, "test-reindex-job", job.Name) + assert.Equal(t, "activity-system", job.Namespace) + assert.Equal(t, "activity-reindex", job.Labels["app"]) + assert.Equal(t, "test-reindex", job.Labels["reindex.activity.miloapis.com/job"]) + + // Verify Job spec + assert.Equal(t, ptr.To(int32(3)), job.Spec.BackoffLimit) + assert.Equal(t, ptr.To(int32(300)), job.Spec.TTLSecondsAfterFinished) + assert.Equal(t, corev1.RestartPolicyOnFailure, job.Spec.Template.Spec.RestartPolicy) + assert.Equal(t, "activity-reindex-worker", job.Spec.Template.Spec.ServiceAccountName) + + // Verify container + require.Len(t, job.Spec.Template.Spec.Containers, 1) + container := job.Spec.Template.Spec.Containers[0] + assert.Equal(t, "reindex", container.Name) + assert.Equal(t, "ghcr.io/datum-cloud/activity:test", container.Image) + + // Verify args + expectedArgs := []string{ + "reindex-worker", + "test-reindex", + "--nats-url=nats://nats.activity-system.svc:4222", + "--logging-format=json", + } + assert.Equal(t, expectedArgs, container.Args) + + // Verify resource limits + memLimit := resource.MustParse("2Gi") + cpuLimit := resource.MustParse("1000m") + assert.Equal(t, memLimit, container.Resources.Limits[corev1.ResourceMemory]) + assert.Equal(t, memLimit, container.Resources.Requests[corev1.ResourceMemory]) + assert.Equal(t, cpuLimit, container.Resources.Limits[corev1.ResourceCPU]) + + // Verify Pod SecurityContext + require.NotNil(t, job.Spec.Template.Spec.SecurityContext) + podSecurityContext := job.Spec.Template.Spec.SecurityContext + assert.Equal(t, ptr.To(true), podSecurityContext.RunAsNonRoot) + assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.RunAsUser) + assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.RunAsGroup) + assert.Equal(t, ptr.To(int64(65532)), podSecurityContext.FSGroup) + require.NotNil(t, podSecurityContext.SeccompProfile) + assert.Equal(t, corev1.SeccompProfileTypeRuntimeDefault, podSecurityContext.SeccompProfile.Type) + + // Verify Container SecurityContext + require.NotNil(t, container.SecurityContext) + containerSecurityContext := container.SecurityContext + assert.Equal(t, ptr.To(false), containerSecurityContext.AllowPrivilegeEscalation) + assert.Equal(t, ptr.To(true), containerSecurityContext.ReadOnlyRootFilesystem) + assert.Equal(t, ptr.To(true), containerSecurityContext.RunAsNonRoot) + require.NotNil(t, containerSecurityContext.Capabilities) + assert.Equal(t, []corev1.Capability{"ALL"}, containerSecurityContext.Capabilities.Drop) +} + +func TestBuildJobForReindexJob_WithTLS(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + + reconciler := &ReindexJobReconciler{ + JobNamespace: "activity-system", + ActivityImage: "ghcr.io/datum-cloud/activity:test", + ReindexServiceAccount: "activity-reindex-worker", + ReindexMemoryLimit: "2Gi", + ReindexCPULimit: "1000m", + NATSURL: "nats://nats.activity-system.svc:4222", + NATSTLSEnabled: true, + NATSTLSCertFile: "/certs/tls.crt", + NATSTLSKeyFile: "/certs/tls.key", + NATSTLSCAFile: "/certs/ca.crt", + } + + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex-tls", + }, + Spec: v1alpha1.ReindexJobSpec{ + TimeRange: v1alpha1.ReindexTimeRange{ + StartTime: "now-7d", + }, + }, + } + + job, err := reconciler.buildJobForReindexJob(reindexJob) + require.NoError(t, err) + + // Verify TLS args are included + container := job.Spec.Template.Spec.Containers[0] + expectedArgs := []string{ + "reindex-worker", + "test-reindex-tls", + "--nats-url=nats://nats.activity-system.svc:4222", + "--logging-format=json", + "--nats-tls-enabled=true", + "--nats-tls-cert-file=/certs/tls.crt", + "--nats-tls-key-file=/certs/tls.key", + "--nats-tls-ca-file=/certs/ca.crt", + } + assert.Equal(t, expectedArgs, container.Args) +} + +func TestCountRunningJobs(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + + now := metav1.Now() + + // Create test jobs + runningJob1 := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-1", + Namespace: "activity-system", + Labels: map[string]string{ + "app": "activity-reindex", + }, + }, + Status: batchv1.JobStatus{ + // No completion time - still running + }, + } + + runningJob2 := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-2", + Namespace: "activity-system", + Labels: map[string]string{ + "app": "activity-reindex", + }, + }, + Status: batchv1.JobStatus{ + // No completion time - still running + }, + } + + completedJob := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "job-3", + Namespace: "activity-system", + Labels: map[string]string{ + "app": "activity-reindex", + }, + }, + Status: batchv1.JobStatus{ + CompletionTime: &now, + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(runningJob1, runningJob2, completedJob). + Build() + + reconciler := &ReindexJobReconciler{ + Client: fakeClient, + JobClient: fakeClient, + JobNamespace: "activity-system", + } + + count, err := reconciler.countRunningJobs(context.Background()) + require.NoError(t, err) + assert.Equal(t, 2, count, "should count only running jobs (without completion time)") +} + +func TestGetJobForReindexJob(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex-job", + Namespace: "activity-system", + }, + } + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(job). + Build() + + reconciler := &ReindexJobReconciler{ + Client: fakeClient, + JobClient: fakeClient, + JobNamespace: "activity-system", + } + + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex", + }, + } + + foundJob, err := reconciler.getJobForReindexJob(context.Background(), reindexJob) + require.NoError(t, err) + assert.Equal(t, "test-reindex-job", foundJob.Name) +} + +func TestCheckJobStatus(t *testing.T) { + t.Parallel() + + scheme := runtime.NewScheme() + require.NoError(t, v1alpha1.AddToScheme(scheme)) + require.NoError(t, batchv1.AddToScheme(scheme)) + + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + recorder := record.NewFakeRecorder(10) + + reconciler := &ReindexJobReconciler{ + Client: fakeClient, + JobClient: fakeClient, + Recorder: recorder, + } + + t.Run("job still running", func(t *testing.T) { + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex", + }, + Status: v1alpha1.ReindexJobStatus{ + Phase: v1alpha1.ReindexJobRunning, + }, + } + + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex-job", + }, + Status: batchv1.JobStatus{ + // No completion time - still running + }, + } + + result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) + require.NoError(t, err) + assert.Greater(t, result.RequeueAfter.Seconds(), float64(0), "should requeue to check again later") + }) + + t.Run("job completed successfully", func(t *testing.T) { + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex", + }, + Status: v1alpha1.ReindexJobStatus{ + Phase: v1alpha1.ReindexJobRunning, + }, + } + + now := metav1.Now() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex-job", + }, + Status: batchv1.JobStatus{ + Succeeded: 1, + CompletionTime: &now, + }, + } + + result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) + require.NoError(t, err) + assert.Equal(t, int64(0), result.RequeueAfter.Nanoseconds(), "should not requeue for completed job") + }) + + t.Run("job failed", func(t *testing.T) { + reindexJob := &v1alpha1.ReindexJob{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex", + }, + Status: v1alpha1.ReindexJobStatus{ + Phase: v1alpha1.ReindexJobRunning, + }, + } + + now := metav1.Now() + job := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-reindex-job", + }, + Status: batchv1.JobStatus{ + Failed: 1, + CompletionTime: &now, + }, + } + + result, err := reconciler.checkJobStatus(context.Background(), reindexJob, job) + require.NoError(t, err) + assert.Equal(t, int64(0), result.RequeueAfter.Nanoseconds(), "should not requeue for completed job") + }) +} diff --git a/internal/processor/activity.go b/internal/processor/activity.go index 0c6316e5..cd120206 100644 --- a/internal/processor/activity.go +++ b/internal/processor/activity.go @@ -1,227 +1,227 @@ -package processor - -import ( - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// ActivityBuilder contains the common fields needed to build an Activity. -type ActivityBuilder struct { - // Resource information from the policy - APIGroup string - Kind string -} - -// BuildFromAudit constructs an Activity from an audit event. -// If resolveKind is provided, it will be used to resolve resource names to Kind in links. -// Returns error if link conversion fails. -func (b *ActivityBuilder) BuildFromAudit( - audit *auditv1.Event, - summary string, - links []cel.Link, - resolveKind KindResolver, -) (*v1alpha1.Activity, error) { - // Extract timestamps - timestamp := audit.RequestReceivedTimestamp.Time - if timestamp.IsZero() { - timestamp = time.Now() - } - - // Extract resource info from ObjectRef - var namespace, resourceName, apiVersion string - if audit.ObjectRef != nil { - namespace = audit.ObjectRef.Namespace - resourceName = audit.ObjectRef.Name - apiVersion = audit.ObjectRef.APIVersion - } - - // Try to get UID from responseObject metadata - resourceUID := extractResponseUID(audit.ResponseObject) - - // Classify change source and resolve actor - changeSource := ClassifyChangeSource(audit.User) - actor := ResolveActor(audit.User) - tenant := ExtractTenant(audit.User) - - // Generate activity name - activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) - - // Convert links - activityLinks, err := ConvertLinks(links, resolveKind) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) - } - - return &v1alpha1.Activity{ - TypeMeta: metav1.TypeMeta{ - APIVersion: v1alpha1.SchemeGroupVersion.String(), - Kind: "Activity", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: activityName, - Namespace: namespace, - CreationTimestamp: metav1.NewTime(timestamp), - Labels: map[string]string{ - "activity.miloapis.com/origin-type": "audit", - "activity.miloapis.com/change-source": changeSource, - "activity.miloapis.com/api-group": b.APIGroup, - "activity.miloapis.com/resource-kind": b.Kind, - }, - }, - Spec: v1alpha1.ActivitySpec{ - Summary: summary, - ChangeSource: changeSource, - Actor: actor, - Resource: v1alpha1.ActivityResource{ - APIGroup: b.APIGroup, - APIVersion: apiVersion, - Kind: b.Kind, - Name: resourceName, - Namespace: namespace, - UID: resourceUID, - }, - Links: activityLinks, - Tenant: tenant, - Origin: v1alpha1.ActivityOrigin{ - Type: "audit", - ID: string(audit.AuditID), - }, - }, - }, nil -} - -// extractResponseUID extracts the UID from an audit response object's metadata. -func extractResponseUID(responseObject *runtime.Unknown) string { - if responseObject == nil || len(responseObject.Raw) == 0 { - return "" - } - - // We still need to unmarshal the raw response to get metadata.uid - var obj struct { - Metadata struct { - UID string `json:"uid"` - } `json:"metadata"` - } - if err := json.Unmarshal(responseObject.Raw, &obj); err != nil { - return "" - } - return obj.Metadata.UID -} - -// BuildFromEvent constructs an Activity from a Kubernetes event. -// If resolveKind is provided, it will be used to resolve resource names to Kind in links. -// Returns error if link conversion fails. -func (b *ActivityBuilder) BuildFromEvent( - eventMap map[string]interface{}, - summary string, - links []cel.Link, - resolveKind KindResolver, -) (*v1alpha1.Activity, error) { - regarding, _ := eventMap["regarding"].(map[string]interface{}) - - // Extract timestamps - var timestamp time.Time - if ts, ok := eventMap["eventTime"].(string); ok { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - if timestamp.IsZero() { - if metadata, ok := eventMap["metadata"].(map[string]interface{}); ok { - if ts, ok := metadata["creationTimestamp"].(string); ok { - if t, err := time.Parse(time.RFC3339, ts); err == nil { - timestamp = t - } - } - } - } - if timestamp.IsZero() { - timestamp = time.Now() - } - - // Extract resource info from regarding - namespace := GetNestedString(regarding, "namespace") - resourceName := GetNestedString(regarding, "name") - resourceUID := GetNestedString(regarding, "uid") - apiVersion := GetNestedString(regarding, "apiVersion") - - // Events are typically system-generated - changeSource := ChangeSourceSystem - - // For events, actor is usually the reporting component - reportingController := GetNestedString(eventMap, "reportingController") - actor := v1alpha1.ActivityActor{ - Type: ActorTypeSystem, - Name: reportingController, - } - if actor.Name == "" { - actor.Name = "unknown" - } - - // Extract tenant info (may not be present in events) - tenant := v1alpha1.ActivityTenant{ - Type: "platform", - Name: "", - } - - // Generate activity name - activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) - - // Convert links - activityLinks, err := ConvertLinks(links, resolveKind) - if err != nil { - return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) - } - - // Get event UID for origin - eventUID := "" - if metadata, ok := eventMap["metadata"].(map[string]interface{}); ok { - eventUID = GetNestedString(metadata, "uid") - } - - return &v1alpha1.Activity{ - TypeMeta: metav1.TypeMeta{ - APIVersion: v1alpha1.SchemeGroupVersion.String(), - Kind: "Activity", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: activityName, - Namespace: namespace, - CreationTimestamp: metav1.NewTime(timestamp), - Labels: map[string]string{ - "activity.miloapis.com/origin-type": "event", - "activity.miloapis.com/change-source": changeSource, - "activity.miloapis.com/api-group": b.APIGroup, - "activity.miloapis.com/resource-kind": b.Kind, - }, - }, - Spec: v1alpha1.ActivitySpec{ - Summary: summary, - ChangeSource: changeSource, - Actor: actor, - Resource: v1alpha1.ActivityResource{ - APIGroup: b.APIGroup, - APIVersion: apiVersion, - Kind: b.Kind, - Name: resourceName, - Namespace: namespace, - UID: resourceUID, - }, - Links: activityLinks, - Tenant: tenant, - Origin: v1alpha1.ActivityOrigin{ - Type: "event", - ID: eventUID, - }, - }, - }, nil -} +package processor + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// ActivityBuilder contains the common fields needed to build an Activity. +type ActivityBuilder struct { + // Resource information from the policy + APIGroup string + Kind string +} + +// BuildFromAudit constructs an Activity from an audit event. +// If resolveKind is provided, it will be used to resolve resource names to Kind in links. +// Returns error if link conversion fails. +func (b *ActivityBuilder) BuildFromAudit( + audit *auditv1.Event, + summary string, + links []cel.Link, + resolveKind KindResolver, +) (*v1alpha1.Activity, error) { + // Extract timestamps + timestamp := audit.RequestReceivedTimestamp.Time + if timestamp.IsZero() { + timestamp = time.Now() + } + + // Extract resource info from ObjectRef + var namespace, resourceName, apiVersion string + if audit.ObjectRef != nil { + namespace = audit.ObjectRef.Namespace + resourceName = audit.ObjectRef.Name + apiVersion = audit.ObjectRef.APIVersion + } + + // Try to get UID from responseObject metadata + resourceUID := extractResponseUID(audit.ResponseObject) + + // Classify change source and resolve actor + changeSource := ClassifyChangeSource(audit.User) + actor := ResolveActor(audit.User) + tenant := ExtractTenant(audit.User) + + // Generate activity name + activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) + + // Convert links + activityLinks, err := ConvertLinks(links, resolveKind) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) + } + + return &v1alpha1.Activity{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.SchemeGroupVersion.String(), + Kind: "Activity", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: activityName, + Namespace: namespace, + CreationTimestamp: metav1.NewTime(timestamp), + Labels: map[string]string{ + "activity.miloapis.com/origin-type": "audit", + "activity.miloapis.com/change-source": changeSource, + "activity.miloapis.com/api-group": b.APIGroup, + "activity.miloapis.com/resource-kind": b.Kind, + }, + }, + Spec: v1alpha1.ActivitySpec{ + Summary: summary, + ChangeSource: changeSource, + Actor: actor, + Resource: v1alpha1.ActivityResource{ + APIGroup: b.APIGroup, + APIVersion: apiVersion, + Kind: b.Kind, + Name: resourceName, + Namespace: namespace, + UID: resourceUID, + }, + Links: activityLinks, + Tenant: tenant, + Origin: v1alpha1.ActivityOrigin{ + Type: "audit", + ID: string(audit.AuditID), + }, + }, + }, nil +} + +// extractResponseUID extracts the UID from an audit response object's metadata. +func extractResponseUID(responseObject *runtime.Unknown) string { + if responseObject == nil || len(responseObject.Raw) == 0 { + return "" + } + + // We still need to unmarshal the raw response to get metadata.uid + var obj struct { + Metadata struct { + UID string `json:"uid"` + } `json:"metadata"` + } + if err := json.Unmarshal(responseObject.Raw, &obj); err != nil { + return "" + } + return obj.Metadata.UID +} + +// BuildFromEvent constructs an Activity from a Kubernetes event. +// If resolveKind is provided, it will be used to resolve resource names to Kind in links. +// Returns error if link conversion fails. +func (b *ActivityBuilder) BuildFromEvent( + eventMap map[string]interface{}, + summary string, + links []cel.Link, + resolveKind KindResolver, +) (*v1alpha1.Activity, error) { + regarding, _ := eventMap["regarding"].(map[string]interface{}) + + // Extract timestamps + var timestamp time.Time + if ts, ok := eventMap["eventTime"].(string); ok { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + timestamp = t + } + } + if timestamp.IsZero() { + if metadata, ok := eventMap["metadata"].(map[string]interface{}); ok { + if ts, ok := metadata["creationTimestamp"].(string); ok { + if t, err := time.Parse(time.RFC3339, ts); err == nil { + timestamp = t + } + } + } + } + if timestamp.IsZero() { + timestamp = time.Now() + } + + // Extract resource info from regarding + namespace := GetNestedString(regarding, "namespace") + resourceName := GetNestedString(regarding, "name") + resourceUID := GetNestedString(regarding, "uid") + apiVersion := GetNestedString(regarding, "apiVersion") + + // Events are typically system-generated + changeSource := ChangeSourceSystem + + // For events, actor is usually the reporting component + reportingController := GetNestedString(eventMap, "reportingController") + actor := v1alpha1.ActivityActor{ + Type: ActorTypeSystem, + Name: reportingController, + } + if actor.Name == "" { + actor.Name = "unknown" + } + + // Extract tenant info (may not be present in events) + tenant := v1alpha1.ActivityTenant{ + Type: "platform", + Name: "", + } + + // Generate activity name + activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) + + // Convert links + activityLinks, err := ConvertLinks(links, resolveKind) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrActivityBuild, err) + } + + // Get event UID for origin + eventUID := "" + if metadata, ok := eventMap["metadata"].(map[string]interface{}); ok { + eventUID = GetNestedString(metadata, "uid") + } + + return &v1alpha1.Activity{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.SchemeGroupVersion.String(), + Kind: "Activity", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: activityName, + Namespace: namespace, + CreationTimestamp: metav1.NewTime(timestamp), + Labels: map[string]string{ + "activity.miloapis.com/origin-type": "event", + "activity.miloapis.com/change-source": changeSource, + "activity.miloapis.com/api-group": b.APIGroup, + "activity.miloapis.com/resource-kind": b.Kind, + }, + }, + Spec: v1alpha1.ActivitySpec{ + Summary: summary, + ChangeSource: changeSource, + Actor: actor, + Resource: v1alpha1.ActivityResource{ + APIGroup: b.APIGroup, + APIVersion: apiVersion, + Kind: b.Kind, + Name: resourceName, + Namespace: namespace, + UID: resourceUID, + }, + Links: activityLinks, + Tenant: tenant, + Origin: v1alpha1.ActivityOrigin{ + Type: "event", + ID: eventUID, + }, + }, + }, nil +} diff --git a/internal/processor/classifier.go b/internal/processor/classifier.go index 84e01ac1..90df26d0 100644 --- a/internal/processor/classifier.go +++ b/internal/processor/classifier.go @@ -1,76 +1,76 @@ -package processor - -import ( - "strings" - - authnv1 "k8s.io/api/authentication/v1" - - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// ChangeSource constants for activity classification. -const ( - ChangeSourceHuman = "human" - ChangeSourceSystem = "system" -) - -// ClassifyChangeSource determines whether an activity was initiated by a human -// or by the system (controllers, service accounts, etc.). -// System accounts always use a "system:" prefix for the username. -func ClassifyChangeSource(user authnv1.UserInfo) string { - if strings.HasPrefix(user.Username, "system:") { - return ChangeSourceSystem - } - - return ChangeSourceHuman -} - -// ActorType constants for actor classification. -const ( - ActorTypeUser = "user" - ActorTypeSystem = "system" - ActorTypeController = "controller" -) - -// ResolveActor extracts actor information from the audit user field. -// -// Actor types: -// - user: Human users authenticated via OIDC or other providers -// - system: Kubernetes controllers, service accounts, and other system components -func ResolveActor(user authnv1.UserInfo) v1alpha1.ActivityActor { - actor := v1alpha1.ActivityActor{ - UID: user.UID, - } - - // Detect actor type based on username pattern - if strings.HasPrefix(user.Username, "system:") { - // System component (controller, service account, node, etc.) - actor.Type = ActorTypeSystem - actor.Name = strings.TrimPrefix(user.Username, "system:") - } else { - // Human user - actor.Type = ActorTypeUser - actor.Name = user.Username - } - - // Populate email field if username looks like an email - if strings.Contains(user.Username, "@") { - actor.Email = user.Username - } - - if actor.Name == "" { - actor.Name = "unknown" - } - - return actor -} - -// IsSystemActor returns true if the actor represents a system component. -func IsSystemActor(actor v1alpha1.ActivityActor) bool { - return actor.Type == ActorTypeSystem -} - -// IsHumanActor returns true if the actor represents a human user. -func IsHumanActor(actor v1alpha1.ActivityActor) bool { - return actor.Type == ActorTypeUser -} +package processor + +import ( + "strings" + + authnv1 "k8s.io/api/authentication/v1" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// ChangeSource constants for activity classification. +const ( + ChangeSourceHuman = "human" + ChangeSourceSystem = "system" +) + +// ClassifyChangeSource determines whether an activity was initiated by a human +// or by the system (controllers, service accounts, etc.). +// System accounts always use a "system:" prefix for the username. +func ClassifyChangeSource(user authnv1.UserInfo) string { + if strings.HasPrefix(user.Username, "system:") { + return ChangeSourceSystem + } + + return ChangeSourceHuman +} + +// ActorType constants for actor classification. +const ( + ActorTypeUser = "user" + ActorTypeSystem = "system" + ActorTypeController = "controller" +) + +// ResolveActor extracts actor information from the audit user field. +// +// Actor types: +// - user: Human users authenticated via OIDC or other providers +// - system: Kubernetes controllers, service accounts, and other system components +func ResolveActor(user authnv1.UserInfo) v1alpha1.ActivityActor { + actor := v1alpha1.ActivityActor{ + UID: user.UID, + } + + // Detect actor type based on username pattern + if strings.HasPrefix(user.Username, "system:") { + // System component (controller, service account, node, etc.) + actor.Type = ActorTypeSystem + actor.Name = strings.TrimPrefix(user.Username, "system:") + } else { + // Human user + actor.Type = ActorTypeUser + actor.Name = user.Username + } + + // Populate email field if username looks like an email + if strings.Contains(user.Username, "@") { + actor.Email = user.Username + } + + if actor.Name == "" { + actor.Name = "unknown" + } + + return actor +} + +// IsSystemActor returns true if the actor represents a system component. +func IsSystemActor(actor v1alpha1.ActivityActor) bool { + return actor.Type == ActorTypeSystem +} + +// IsHumanActor returns true if the actor represents a human user. +func IsHumanActor(actor v1alpha1.ActivityActor) bool { + return actor.Type == ActorTypeUser +} diff --git a/internal/processor/dlq_test.go b/internal/processor/dlq_test.go index 3eea6f40..ed46e95d 100644 --- a/internal/processor/dlq_test.go +++ b/internal/processor/dlq_test.go @@ -1,515 +1,515 @@ -package processor - -import ( - "context" - "encoding/json" - "errors" - "strings" - "testing" -) - -func TestPolicyEvaluationError(t *testing.T) { - tests := []struct { - name string - policyName string - ruleIndex int - err error - }{ - { - name: "basic error", - policyName: "test-policy", - ruleIndex: 0, - err: errors.New("CEL evaluation failed"), - }, - { - name: "negative rule index", - policyName: "test-policy", - ruleIndex: -1, - err: errors.New("pre-rule error"), - }, - { - name: "empty policy name", - policyName: "", - ruleIndex: 2, - err: errors.New("some error"), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - policyErr := NewPolicyEvaluationError(tt.policyName, tt.ruleIndex, tt.err) - - if policyErr.PolicyName != tt.policyName { - t.Errorf("PolicyName = %q, want %q", policyErr.PolicyName, tt.policyName) - } - if policyErr.RuleIndex != tt.ruleIndex { - t.Errorf("RuleIndex = %d, want %d", policyErr.RuleIndex, tt.ruleIndex) - } - if policyErr.Error() != tt.err.Error() { - t.Errorf("Error() = %q, want %q", policyErr.Error(), tt.err.Error()) - } - if policyErr.Unwrap() != tt.err { - t.Errorf("Unwrap() = %v, want %v", policyErr.Unwrap(), tt.err) - } - }) - } -} - -func TestPolicyEvaluationErrorIs(t *testing.T) { - originalErr := errors.New("original error") - policyErr := NewPolicyEvaluationError("test-policy", 1, originalErr) - - var unwrapped *PolicyEvaluationError - if !errors.As(policyErr, &unwrapped) { - t.Error("errors.As should succeed for PolicyEvaluationError") - } - if unwrapped.PolicyName != "test-policy" { - t.Errorf("unwrapped PolicyName = %q, want %q", unwrapped.PolicyName, "test-policy") - } - if unwrapped.RuleIndex != 1 { - t.Errorf("unwrapped RuleIndex = %d, want %d", unwrapped.RuleIndex, 1) - } -} - -func TestNoopDLQPublisher(t *testing.T) { - publisher := &noopDLQPublisher{} - ctx := context.Background() - payload := json.RawMessage(`{"test": "data"}`) - testErr := errors.New("test error") - - t.Run("PublishAuditFailure returns nil", func(t *testing.T) { - err := publisher.PublishAuditFailure(ctx, payload, "policy", 1, 0, ErrorTypeCELMatch, testErr, nil, nil) - if err != nil { - t.Errorf("PublishAuditFailure() returned error: %v", err) - } - }) - - t.Run("PublishEventFailure returns nil", func(t *testing.T) { - err := publisher.PublishEventFailure(ctx, payload, "policy", 1, 0, ErrorTypeCELMatch, testErr, nil, nil) - if err != nil { - t.Errorf("PublishEventFailure() returned error: %v", err) - } - }) -} - -func TestDefaultDLQConfig(t *testing.T) { - config := DefaultDLQConfig() - - if !config.Enabled { - t.Error("default config should have Enabled = true") - } - if config.StreamName != "ACTIVITY_DEAD_LETTER" { - t.Errorf("StreamName = %q, want %q", config.StreamName, "ACTIVITY_DEAD_LETTER") - } - if config.SubjectPrefix != "activity.dlq" { - t.Errorf("SubjectPrefix = %q, want %q", config.SubjectPrefix, "activity.dlq") - } -} - -func TestNewDLQPublisher_Disabled(t *testing.T) { - config := DLQConfig{ - Enabled: false, - } - publisher := NewDLQPublisher(nil, config) - - // Should return a noop publisher - _, isNoop := publisher.(*noopDLQPublisher) - if !isNoop { - t.Error("NewDLQPublisher with disabled config should return noopDLQPublisher") - } -} - -func TestErrorTypes(t *testing.T) { - // Verify error type constants are distinct - errorTypes := []ErrorType{ - ErrorTypeCELMatch, - ErrorTypeCELSummary, - ErrorTypeUnmarshal, - ErrorTypeKindResolve, - } - - seen := make(map[ErrorType]bool) - for _, et := range errorTypes { - if seen[et] { - t.Errorf("duplicate error type: %s", et) - } - seen[et] = true - } -} - -func TestEventTypes(t *testing.T) { - // Verify event type constants are distinct - if EventTypeAudit == EventTypeK8sEvent { - t.Error("EventTypeAudit and EventTypeK8sEvent should be different") - } - - if EventTypeAudit != "audit" { - t.Errorf("EventTypeAudit = %q, want %q", EventTypeAudit, "audit") - } - if EventTypeK8sEvent != "k8s-event" { - t.Errorf("EventTypeK8sEvent = %q, want %q", EventTypeK8sEvent, "k8s-event") - } -} - -func TestDeadLetterEventSerialization(t *testing.T) { - dlEvent := DeadLetterEvent{ - Type: EventTypeAudit, - OriginalPayload: json.RawMessage(`{"verb": "create"}`), - Error: "CEL evaluation failed", - ErrorType: ErrorTypeCELMatch, - PolicyName: "test-policy", - RuleIndex: 1, - Resource: &DeadLetterResource{ - APIGroup: "apps", - Kind: "Deployment", - Name: "my-deployment", - Namespace: "default", - }, - Tenant: &DeadLetterTenant{ - Type: "project", - Name: "my-project", - }, - } - - data, err := json.Marshal(dlEvent) - if err != nil { - t.Fatalf("failed to marshal DeadLetterEvent: %v", err) - } - - var unmarshaled DeadLetterEvent - if err := json.Unmarshal(data, &unmarshaled); err != nil { - t.Fatalf("failed to unmarshal DeadLetterEvent: %v", err) - } - - if unmarshaled.Type != dlEvent.Type { - t.Errorf("Type = %q, want %q", unmarshaled.Type, dlEvent.Type) - } - if unmarshaled.PolicyName != dlEvent.PolicyName { - t.Errorf("PolicyName = %q, want %q", unmarshaled.PolicyName, dlEvent.PolicyName) - } - if unmarshaled.RuleIndex != dlEvent.RuleIndex { - t.Errorf("RuleIndex = %d, want %d", unmarshaled.RuleIndex, dlEvent.RuleIndex) - } - if unmarshaled.Resource == nil { - t.Fatal("Resource should not be nil") - } - if unmarshaled.Resource.Kind != "Deployment" { - t.Errorf("Resource.Kind = %q, want %q", unmarshaled.Resource.Kind, "Deployment") - } - if unmarshaled.Tenant == nil { - t.Fatal("Tenant should not be nil") - } - if unmarshaled.Tenant.Type != "project" { - t.Errorf("Tenant.Type = %q, want %q", unmarshaled.Tenant.Type, "project") - } - if unmarshaled.Tenant.Name != "my-project" { - t.Errorf("Tenant.Name = %q, want %q", unmarshaled.Tenant.Name, "my-project") - } -} - -func TestDeadLetterEventSerializationOmitEmpty(t *testing.T) { - dlEvent := DeadLetterEvent{ - Type: EventTypeK8sEvent, - OriginalPayload: json.RawMessage(`{}`), - Error: "test error", - ErrorType: ErrorTypeUnmarshal, - RuleIndex: -1, - // Resource and Tenant are nil - } - - data, err := json.Marshal(dlEvent) - if err != nil { - t.Fatalf("failed to marshal DeadLetterEvent: %v", err) - } - - // Verify omitempty works - dataStr := string(data) - if strings.Contains(dataStr, `"resource"`) && !strings.Contains(dataStr, `"resource":null`) { - // The resource field should be omitted entirely or be null - var m map[string]interface{} - json.Unmarshal(data, &m) - if _, hasResource := m["resource"]; hasResource && m["resource"] != nil { - t.Error("resource should be omitted when nil") - } - } - if strings.Contains(dataStr, `"tenant"`) && !strings.Contains(dataStr, `"tenant":null`) { - var m map[string]interface{} - json.Unmarshal(data, &m) - if _, hasTenant := m["tenant"]; hasTenant && m["tenant"] != nil { - t.Error("tenant should be omitted when nil") - } - } -} - -// mockPublishedMessage stores published messages for verification. -type mockPublishedMessage struct { - Subject string - Data []byte -} - -// testPublisher is a test wrapper that captures published messages. -// It uses a function-based approach to avoid implementing the full JetStreamContext interface. -type testPublisher struct { - published []mockPublishedMessage - publishFunc func(subj string, data []byte) error -} - -func (t *testPublisher) publish(ctx context.Context, eventType EventType, payload json.RawMessage, policyName string, ruleIndex int, errorType ErrorType, originalErr error, resource *DeadLetterResource, tenant *DeadLetterTenant) error { - // Safely extract error message - errMsg := "" - if originalErr != nil { - errMsg = originalErr.Error() - } - - dlEvent := DeadLetterEvent{ - Type: eventType, - OriginalPayload: payload, - Error: errMsg, - ErrorType: errorType, - PolicyName: policyName, - RuleIndex: ruleIndex, - Resource: resource, - Tenant: tenant, - } - - data, err := json.Marshal(dlEvent) - if err != nil { - return err - } - - // Build subject: ... - apiGroup := "unknown" - kind := "unknown" - if resource != nil { - if resource.APIGroup != "" { - apiGroup = resource.APIGroup - } else { - apiGroup = "core" - } - if resource.Kind != "" { - kind = resource.Kind - } - } - subject := "test.dlq." + string(eventType) + "." + apiGroup + "." + kind - - t.published = append(t.published, mockPublishedMessage{Subject: subject, Data: data}) - - if t.publishFunc != nil { - return t.publishFunc(subject, data) - } - return nil -} - -func TestDLQPublish_SubjectConstruction(t *testing.T) { - tests := []struct { - name string - eventType EventType - resource *DeadLetterResource - wantSubjectPart string - }{ - { - name: "audit with full resource", - eventType: EventTypeAudit, - resource: &DeadLetterResource{ - APIGroup: "apps", - Kind: "Deployment", - }, - wantSubjectPart: "test.dlq.audit.apps.Deployment", - }, - { - name: "k8s-event with core resource", - eventType: EventTypeK8sEvent, - resource: &DeadLetterResource{ - APIGroup: "", // Core resources have empty apiGroup - Kind: "Pod", - }, - wantSubjectPart: "test.dlq.k8s-event.core.Pod", - }, - { - name: "nil resource uses unknown", - eventType: EventTypeAudit, - resource: nil, - wantSubjectPart: "test.dlq.audit.unknown.unknown", - }, - { - name: "resource with empty kind", - eventType: EventTypeK8sEvent, - resource: &DeadLetterResource{ - APIGroup: "networking.k8s.io", - Kind: "", - }, - wantSubjectPart: "test.dlq.k8s-event.networking.k8s.io.unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - publisher := &testPublisher{} - ctx := context.Background() - payload := json.RawMessage(`{"test": true}`) - testErr := errors.New("test error") - - err := publisher.publish(ctx, tt.eventType, payload, "policy", 0, ErrorTypeCELMatch, testErr, tt.resource, nil) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(publisher.published) != 1 { - t.Fatalf("expected 1 published message, got %d", len(publisher.published)) - } - - if publisher.published[0].Subject != tt.wantSubjectPart { - t.Errorf("subject = %q, want %q", publisher.published[0].Subject, tt.wantSubjectPart) - } - }) - } -} - -func TestDLQPublish_PublishFailure(t *testing.T) { - publishErr := errors.New("NATS connection failed") - publisher := &testPublisher{ - publishFunc: func(subj string, data []byte) error { - return publishErr - }, - } - - ctx := context.Background() - payload := json.RawMessage(`{"test": true}`) - testErr := errors.New("original error") - - err := publisher.publish(ctx, EventTypeAudit, payload, "policy", 0, ErrorTypeCELMatch, testErr, nil, nil) - - if err == nil { - t.Fatal("expected error, got nil") - } - - if !strings.Contains(err.Error(), "NATS connection failed") { - t.Errorf("error message = %q, want to contain %q", err.Error(), "NATS connection failed") - } -} - -func TestDLQPublish_NilError(t *testing.T) { - publisher := &testPublisher{} - ctx := context.Background() - payload := json.RawMessage(`{"test": true}`) - - // Test with nil error (should not panic) - err := publisher.publish(ctx, EventTypeAudit, payload, "policy", 0, ErrorTypeCELMatch, nil, nil, nil) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(publisher.published) != 1 { - t.Fatalf("expected 1 published message, got %d", len(publisher.published)) - } - - // Verify the error field is empty in the published message - var dlEvent DeadLetterEvent - if err := json.Unmarshal(publisher.published[0].Data, &dlEvent); err != nil { - t.Fatalf("failed to unmarshal published data: %v", err) - } - - if dlEvent.Error != "" { - t.Errorf("Error field = %q, want empty string", dlEvent.Error) - } -} - -func TestDLQPublish_PayloadPreserved(t *testing.T) { - publisher := &testPublisher{} - ctx := context.Background() - originalPayload := json.RawMessage(`{"verb": "create", "objectRef": {"name": "test-pod"}}`) - testErr := errors.New("evaluation failed") - resource := &DeadLetterResource{ - APIGroup: "apps", - Kind: "Deployment", - Name: "my-deployment", - Namespace: "default", - } - tenant := &DeadLetterTenant{ - Type: "project", - Name: "my-project", - } - - err := publisher.publish(ctx, EventTypeAudit, originalPayload, "test-policy", 2, ErrorTypeCELSummary, testErr, resource, tenant) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(publisher.published) != 1 { - t.Fatalf("expected 1 published message, got %d", len(publisher.published)) - } - - var dlEvent DeadLetterEvent - if err := json.Unmarshal(publisher.published[0].Data, &dlEvent); err != nil { - t.Fatalf("failed to unmarshal published data: %v", err) - } - - // Verify all fields are preserved correctly - if dlEvent.Type != EventTypeAudit { - t.Errorf("Type = %q, want %q", dlEvent.Type, EventTypeAudit) - } - // Compare JSON payloads by unmarshaling to handle whitespace differences - var originalParsed, preservedParsed map[string]interface{} - json.Unmarshal(originalPayload, &originalParsed) - json.Unmarshal(dlEvent.OriginalPayload, &preservedParsed) - if originalParsed["verb"] != preservedParsed["verb"] { - t.Errorf("OriginalPayload verb mismatch: got %v, want %v", preservedParsed["verb"], originalParsed["verb"]) - } - if dlEvent.Error != "evaluation failed" { - t.Errorf("Error = %q, want %q", dlEvent.Error, "evaluation failed") - } - if dlEvent.ErrorType != ErrorTypeCELSummary { - t.Errorf("ErrorType = %q, want %q", dlEvent.ErrorType, ErrorTypeCELSummary) - } - if dlEvent.PolicyName != "test-policy" { - t.Errorf("PolicyName = %q, want %q", dlEvent.PolicyName, "test-policy") - } - if dlEvent.RuleIndex != 2 { - t.Errorf("RuleIndex = %d, want %d", dlEvent.RuleIndex, 2) - } - if dlEvent.Resource == nil { - t.Fatal("Resource should not be nil") - } - if dlEvent.Resource.Kind != "Deployment" { - t.Errorf("Resource.Kind = %q, want %q", dlEvent.Resource.Kind, "Deployment") - } - if dlEvent.Tenant == nil { - t.Fatal("Tenant should not be nil") - } - if dlEvent.Tenant.Name != "my-project" { - t.Errorf("Tenant.Name = %q, want %q", dlEvent.Tenant.Name, "my-project") - } -} - -func TestSentinelErrors(t *testing.T) { - // Test that sentinel errors work correctly with errors.Is - t.Run("ErrKindResolution", func(t *testing.T) { - wrapped := errors.New("discovery cache miss") - err := errors.Join(ErrKindResolution, wrapped) - - if !errors.Is(err, ErrKindResolution) { - t.Error("errors.Is should return true for wrapped ErrKindResolution") - } - }) - - t.Run("ErrActivityBuild", func(t *testing.T) { - wrapped := errors.New("link conversion failed") - err := errors.Join(ErrActivityBuild, wrapped) - - if !errors.Is(err, ErrActivityBuild) { - t.Error("errors.Is should return true for wrapped ErrActivityBuild") - } - }) - - t.Run("ErrKindResolution is distinct from ErrActivityBuild", func(t *testing.T) { - if errors.Is(ErrKindResolution, ErrActivityBuild) { - t.Error("ErrKindResolution should not match ErrActivityBuild") - } - if errors.Is(ErrActivityBuild, ErrKindResolution) { - t.Error("ErrActivityBuild should not match ErrKindResolution") - } - }) -} +package processor + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestPolicyEvaluationError(t *testing.T) { + tests := []struct { + name string + policyName string + ruleIndex int + err error + }{ + { + name: "basic error", + policyName: "test-policy", + ruleIndex: 0, + err: errors.New("CEL evaluation failed"), + }, + { + name: "negative rule index", + policyName: "test-policy", + ruleIndex: -1, + err: errors.New("pre-rule error"), + }, + { + name: "empty policy name", + policyName: "", + ruleIndex: 2, + err: errors.New("some error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policyErr := NewPolicyEvaluationError(tt.policyName, tt.ruleIndex, tt.err) + + if policyErr.PolicyName != tt.policyName { + t.Errorf("PolicyName = %q, want %q", policyErr.PolicyName, tt.policyName) + } + if policyErr.RuleIndex != tt.ruleIndex { + t.Errorf("RuleIndex = %d, want %d", policyErr.RuleIndex, tt.ruleIndex) + } + if policyErr.Error() != tt.err.Error() { + t.Errorf("Error() = %q, want %q", policyErr.Error(), tt.err.Error()) + } + if policyErr.Unwrap() != tt.err { + t.Errorf("Unwrap() = %v, want %v", policyErr.Unwrap(), tt.err) + } + }) + } +} + +func TestPolicyEvaluationErrorIs(t *testing.T) { + originalErr := errors.New("original error") + policyErr := NewPolicyEvaluationError("test-policy", 1, originalErr) + + var unwrapped *PolicyEvaluationError + if !errors.As(policyErr, &unwrapped) { + t.Error("errors.As should succeed for PolicyEvaluationError") + } + if unwrapped.PolicyName != "test-policy" { + t.Errorf("unwrapped PolicyName = %q, want %q", unwrapped.PolicyName, "test-policy") + } + if unwrapped.RuleIndex != 1 { + t.Errorf("unwrapped RuleIndex = %d, want %d", unwrapped.RuleIndex, 1) + } +} + +func TestNoopDLQPublisher(t *testing.T) { + publisher := &noopDLQPublisher{} + ctx := context.Background() + payload := json.RawMessage(`{"test": "data"}`) + testErr := errors.New("test error") + + t.Run("PublishAuditFailure returns nil", func(t *testing.T) { + err := publisher.PublishAuditFailure(ctx, payload, "policy", 1, 0, ErrorTypeCELMatch, testErr, nil, nil) + if err != nil { + t.Errorf("PublishAuditFailure() returned error: %v", err) + } + }) + + t.Run("PublishEventFailure returns nil", func(t *testing.T) { + err := publisher.PublishEventFailure(ctx, payload, "policy", 1, 0, ErrorTypeCELMatch, testErr, nil, nil) + if err != nil { + t.Errorf("PublishEventFailure() returned error: %v", err) + } + }) +} + +func TestDefaultDLQConfig(t *testing.T) { + config := DefaultDLQConfig() + + if !config.Enabled { + t.Error("default config should have Enabled = true") + } + if config.StreamName != "ACTIVITY_DEAD_LETTER" { + t.Errorf("StreamName = %q, want %q", config.StreamName, "ACTIVITY_DEAD_LETTER") + } + if config.SubjectPrefix != "activity.dlq" { + t.Errorf("SubjectPrefix = %q, want %q", config.SubjectPrefix, "activity.dlq") + } +} + +func TestNewDLQPublisher_Disabled(t *testing.T) { + config := DLQConfig{ + Enabled: false, + } + publisher := NewDLQPublisher(nil, config) + + // Should return a noop publisher + _, isNoop := publisher.(*noopDLQPublisher) + if !isNoop { + t.Error("NewDLQPublisher with disabled config should return noopDLQPublisher") + } +} + +func TestErrorTypes(t *testing.T) { + // Verify error type constants are distinct + errorTypes := []ErrorType{ + ErrorTypeCELMatch, + ErrorTypeCELSummary, + ErrorTypeUnmarshal, + ErrorTypeKindResolve, + } + + seen := make(map[ErrorType]bool) + for _, et := range errorTypes { + if seen[et] { + t.Errorf("duplicate error type: %s", et) + } + seen[et] = true + } +} + +func TestEventTypes(t *testing.T) { + // Verify event type constants are distinct + if EventTypeAudit == EventTypeK8sEvent { + t.Error("EventTypeAudit and EventTypeK8sEvent should be different") + } + + if EventTypeAudit != "audit" { + t.Errorf("EventTypeAudit = %q, want %q", EventTypeAudit, "audit") + } + if EventTypeK8sEvent != "k8s-event" { + t.Errorf("EventTypeK8sEvent = %q, want %q", EventTypeK8sEvent, "k8s-event") + } +} + +func TestDeadLetterEventSerialization(t *testing.T) { + dlEvent := DeadLetterEvent{ + Type: EventTypeAudit, + OriginalPayload: json.RawMessage(`{"verb": "create"}`), + Error: "CEL evaluation failed", + ErrorType: ErrorTypeCELMatch, + PolicyName: "test-policy", + RuleIndex: 1, + Resource: &DeadLetterResource{ + APIGroup: "apps", + Kind: "Deployment", + Name: "my-deployment", + Namespace: "default", + }, + Tenant: &DeadLetterTenant{ + Type: "project", + Name: "my-project", + }, + } + + data, err := json.Marshal(dlEvent) + if err != nil { + t.Fatalf("failed to marshal DeadLetterEvent: %v", err) + } + + var unmarshaled DeadLetterEvent + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("failed to unmarshal DeadLetterEvent: %v", err) + } + + if unmarshaled.Type != dlEvent.Type { + t.Errorf("Type = %q, want %q", unmarshaled.Type, dlEvent.Type) + } + if unmarshaled.PolicyName != dlEvent.PolicyName { + t.Errorf("PolicyName = %q, want %q", unmarshaled.PolicyName, dlEvent.PolicyName) + } + if unmarshaled.RuleIndex != dlEvent.RuleIndex { + t.Errorf("RuleIndex = %d, want %d", unmarshaled.RuleIndex, dlEvent.RuleIndex) + } + if unmarshaled.Resource == nil { + t.Fatal("Resource should not be nil") + } + if unmarshaled.Resource.Kind != "Deployment" { + t.Errorf("Resource.Kind = %q, want %q", unmarshaled.Resource.Kind, "Deployment") + } + if unmarshaled.Tenant == nil { + t.Fatal("Tenant should not be nil") + } + if unmarshaled.Tenant.Type != "project" { + t.Errorf("Tenant.Type = %q, want %q", unmarshaled.Tenant.Type, "project") + } + if unmarshaled.Tenant.Name != "my-project" { + t.Errorf("Tenant.Name = %q, want %q", unmarshaled.Tenant.Name, "my-project") + } +} + +func TestDeadLetterEventSerializationOmitEmpty(t *testing.T) { + dlEvent := DeadLetterEvent{ + Type: EventTypeK8sEvent, + OriginalPayload: json.RawMessage(`{}`), + Error: "test error", + ErrorType: ErrorTypeUnmarshal, + RuleIndex: -1, + // Resource and Tenant are nil + } + + data, err := json.Marshal(dlEvent) + if err != nil { + t.Fatalf("failed to marshal DeadLetterEvent: %v", err) + } + + // Verify omitempty works + dataStr := string(data) + if strings.Contains(dataStr, `"resource"`) && !strings.Contains(dataStr, `"resource":null`) { + // The resource field should be omitted entirely or be null + var m map[string]interface{} + json.Unmarshal(data, &m) + if _, hasResource := m["resource"]; hasResource && m["resource"] != nil { + t.Error("resource should be omitted when nil") + } + } + if strings.Contains(dataStr, `"tenant"`) && !strings.Contains(dataStr, `"tenant":null`) { + var m map[string]interface{} + json.Unmarshal(data, &m) + if _, hasTenant := m["tenant"]; hasTenant && m["tenant"] != nil { + t.Error("tenant should be omitted when nil") + } + } +} + +// mockPublishedMessage stores published messages for verification. +type mockPublishedMessage struct { + Subject string + Data []byte +} + +// testPublisher is a test wrapper that captures published messages. +// It uses a function-based approach to avoid implementing the full JetStreamContext interface. +type testPublisher struct { + published []mockPublishedMessage + publishFunc func(subj string, data []byte) error +} + +func (t *testPublisher) publish(ctx context.Context, eventType EventType, payload json.RawMessage, policyName string, ruleIndex int, errorType ErrorType, originalErr error, resource *DeadLetterResource, tenant *DeadLetterTenant) error { + // Safely extract error message + errMsg := "" + if originalErr != nil { + errMsg = originalErr.Error() + } + + dlEvent := DeadLetterEvent{ + Type: eventType, + OriginalPayload: payload, + Error: errMsg, + ErrorType: errorType, + PolicyName: policyName, + RuleIndex: ruleIndex, + Resource: resource, + Tenant: tenant, + } + + data, err := json.Marshal(dlEvent) + if err != nil { + return err + } + + // Build subject: ... + apiGroup := "unknown" + kind := "unknown" + if resource != nil { + if resource.APIGroup != "" { + apiGroup = resource.APIGroup + } else { + apiGroup = "core" + } + if resource.Kind != "" { + kind = resource.Kind + } + } + subject := "test.dlq." + string(eventType) + "." + apiGroup + "." + kind + + t.published = append(t.published, mockPublishedMessage{Subject: subject, Data: data}) + + if t.publishFunc != nil { + return t.publishFunc(subject, data) + } + return nil +} + +func TestDLQPublish_SubjectConstruction(t *testing.T) { + tests := []struct { + name string + eventType EventType + resource *DeadLetterResource + wantSubjectPart string + }{ + { + name: "audit with full resource", + eventType: EventTypeAudit, + resource: &DeadLetterResource{ + APIGroup: "apps", + Kind: "Deployment", + }, + wantSubjectPart: "test.dlq.audit.apps.Deployment", + }, + { + name: "k8s-event with core resource", + eventType: EventTypeK8sEvent, + resource: &DeadLetterResource{ + APIGroup: "", // Core resources have empty apiGroup + Kind: "Pod", + }, + wantSubjectPart: "test.dlq.k8s-event.core.Pod", + }, + { + name: "nil resource uses unknown", + eventType: EventTypeAudit, + resource: nil, + wantSubjectPart: "test.dlq.audit.unknown.unknown", + }, + { + name: "resource with empty kind", + eventType: EventTypeK8sEvent, + resource: &DeadLetterResource{ + APIGroup: "networking.k8s.io", + Kind: "", + }, + wantSubjectPart: "test.dlq.k8s-event.networking.k8s.io.unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + publisher := &testPublisher{} + ctx := context.Background() + payload := json.RawMessage(`{"test": true}`) + testErr := errors.New("test error") + + err := publisher.publish(ctx, tt.eventType, payload, "policy", 0, ErrorTypeCELMatch, testErr, tt.resource, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(publisher.published) != 1 { + t.Fatalf("expected 1 published message, got %d", len(publisher.published)) + } + + if publisher.published[0].Subject != tt.wantSubjectPart { + t.Errorf("subject = %q, want %q", publisher.published[0].Subject, tt.wantSubjectPart) + } + }) + } +} + +func TestDLQPublish_PublishFailure(t *testing.T) { + publishErr := errors.New("NATS connection failed") + publisher := &testPublisher{ + publishFunc: func(subj string, data []byte) error { + return publishErr + }, + } + + ctx := context.Background() + payload := json.RawMessage(`{"test": true}`) + testErr := errors.New("original error") + + err := publisher.publish(ctx, EventTypeAudit, payload, "policy", 0, ErrorTypeCELMatch, testErr, nil, nil) + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "NATS connection failed") { + t.Errorf("error message = %q, want to contain %q", err.Error(), "NATS connection failed") + } +} + +func TestDLQPublish_NilError(t *testing.T) { + publisher := &testPublisher{} + ctx := context.Background() + payload := json.RawMessage(`{"test": true}`) + + // Test with nil error (should not panic) + err := publisher.publish(ctx, EventTypeAudit, payload, "policy", 0, ErrorTypeCELMatch, nil, nil, nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(publisher.published) != 1 { + t.Fatalf("expected 1 published message, got %d", len(publisher.published)) + } + + // Verify the error field is empty in the published message + var dlEvent DeadLetterEvent + if err := json.Unmarshal(publisher.published[0].Data, &dlEvent); err != nil { + t.Fatalf("failed to unmarshal published data: %v", err) + } + + if dlEvent.Error != "" { + t.Errorf("Error field = %q, want empty string", dlEvent.Error) + } +} + +func TestDLQPublish_PayloadPreserved(t *testing.T) { + publisher := &testPublisher{} + ctx := context.Background() + originalPayload := json.RawMessage(`{"verb": "create", "objectRef": {"name": "test-pod"}}`) + testErr := errors.New("evaluation failed") + resource := &DeadLetterResource{ + APIGroup: "apps", + Kind: "Deployment", + Name: "my-deployment", + Namespace: "default", + } + tenant := &DeadLetterTenant{ + Type: "project", + Name: "my-project", + } + + err := publisher.publish(ctx, EventTypeAudit, originalPayload, "test-policy", 2, ErrorTypeCELSummary, testErr, resource, tenant) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(publisher.published) != 1 { + t.Fatalf("expected 1 published message, got %d", len(publisher.published)) + } + + var dlEvent DeadLetterEvent + if err := json.Unmarshal(publisher.published[0].Data, &dlEvent); err != nil { + t.Fatalf("failed to unmarshal published data: %v", err) + } + + // Verify all fields are preserved correctly + if dlEvent.Type != EventTypeAudit { + t.Errorf("Type = %q, want %q", dlEvent.Type, EventTypeAudit) + } + // Compare JSON payloads by unmarshaling to handle whitespace differences + var originalParsed, preservedParsed map[string]interface{} + json.Unmarshal(originalPayload, &originalParsed) + json.Unmarshal(dlEvent.OriginalPayload, &preservedParsed) + if originalParsed["verb"] != preservedParsed["verb"] { + t.Errorf("OriginalPayload verb mismatch: got %v, want %v", preservedParsed["verb"], originalParsed["verb"]) + } + if dlEvent.Error != "evaluation failed" { + t.Errorf("Error = %q, want %q", dlEvent.Error, "evaluation failed") + } + if dlEvent.ErrorType != ErrorTypeCELSummary { + t.Errorf("ErrorType = %q, want %q", dlEvent.ErrorType, ErrorTypeCELSummary) + } + if dlEvent.PolicyName != "test-policy" { + t.Errorf("PolicyName = %q, want %q", dlEvent.PolicyName, "test-policy") + } + if dlEvent.RuleIndex != 2 { + t.Errorf("RuleIndex = %d, want %d", dlEvent.RuleIndex, 2) + } + if dlEvent.Resource == nil { + t.Fatal("Resource should not be nil") + } + if dlEvent.Resource.Kind != "Deployment" { + t.Errorf("Resource.Kind = %q, want %q", dlEvent.Resource.Kind, "Deployment") + } + if dlEvent.Tenant == nil { + t.Fatal("Tenant should not be nil") + } + if dlEvent.Tenant.Name != "my-project" { + t.Errorf("Tenant.Name = %q, want %q", dlEvent.Tenant.Name, "my-project") + } +} + +func TestSentinelErrors(t *testing.T) { + // Test that sentinel errors work correctly with errors.Is + t.Run("ErrKindResolution", func(t *testing.T) { + wrapped := errors.New("discovery cache miss") + err := errors.Join(ErrKindResolution, wrapped) + + if !errors.Is(err, ErrKindResolution) { + t.Error("errors.Is should return true for wrapped ErrKindResolution") + } + }) + + t.Run("ErrActivityBuild", func(t *testing.T) { + wrapped := errors.New("link conversion failed") + err := errors.Join(ErrActivityBuild, wrapped) + + if !errors.Is(err, ErrActivityBuild) { + t.Error("errors.Is should return true for wrapped ErrActivityBuild") + } + }) + + t.Run("ErrKindResolution is distinct from ErrActivityBuild", func(t *testing.T) { + if errors.Is(ErrKindResolution, ErrActivityBuild) { + t.Error("ErrKindResolution should not match ErrActivityBuild") + } + if errors.Is(ErrActivityBuild, ErrKindResolution) { + t.Error("ErrActivityBuild should not match ErrKindResolution") + } + }) +} diff --git a/internal/processor/evaluate.go b/internal/processor/evaluate.go index 28ae8f26..66e64a27 100644 --- a/internal/processor/evaluate.go +++ b/internal/processor/evaluate.go @@ -1,161 +1,161 @@ -package processor - -import ( - "encoding/json" - "fmt" - - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// EvaluationResult contains the result of evaluating policy rules against an input. -type EvaluationResult struct { - // Activity is the generated activity, or nil if no rule matched - Activity *v1alpha1.Activity - - // MatchedRuleIndex is the index of the rule that matched, or -1 if none matched - MatchedRuleIndex int - - // MatchedRuleType is "audit" or "event" depending on which rule matched - MatchedRuleType string - - // MatchedRuleName is the name of the matched rule from the policy spec - MatchedRuleName string -} - -// EvaluateAuditRules evaluates audit rules against an audit log input. -// Returns the generated Activity if a rule matches, or nil if no rule matched. -// If resolveKind is provided, it will be used to resolve resource names to Kind in links. -func EvaluateAuditRules( - spec *v1alpha1.ActivityPolicySpec, - audit *auditv1.Event, - resolveKind KindResolver, -) (*EvaluationResult, error) { - // Convert to map for CEL evaluation - auditMap, err := toMap(audit) - if err != nil { - return nil, fmt.Errorf("failed to convert audit data: %w", err) - } - - // Create activity builder - builder := &ActivityBuilder{ - APIGroup: spec.Resource.APIGroup, - Kind: spec.Resource.Kind, - } - - // Try each audit rule in order - for i, rule := range spec.AuditRules { - matched, err := cel.EvaluateAuditMatchMap(rule.Match, auditMap) - if err != nil { - return nil, fmt.Errorf("failed to evaluate rule %d match: %w", i, err) - } - - if matched { - // Generate summary - summary, links, err := cel.EvaluateAuditSummaryMap(rule.Summary, auditMap) - if err != nil { - return nil, fmt.Errorf("failed to evaluate rule %d summary: %w", i, err) - } - - // Build the Activity - activity, err := builder.BuildFromAudit(audit, summary, links, resolveKind) - if err != nil { - return nil, fmt.Errorf("failed to build activity for rule %d: %w", i, err) - } - - return &EvaluationResult{ - Activity: activity, - MatchedRuleIndex: i, - MatchedRuleType: "audit", - MatchedRuleName: rule.Name, - }, nil - } - } - - // No rule matched - return &EvaluationResult{ - MatchedRuleIndex: -1, - }, nil -} - -// EvaluateEventRules evaluates event rules against a Kubernetes event input. -// Returns the generated Activity if a rule matches, or nil if no rule matched. -// If resolveKind is provided, it will be used to resolve resource names to Kind in links. -func EvaluateEventRules( - spec *v1alpha1.ActivityPolicySpec, - eventData interface{}, - resolveKind KindResolver, -) (*EvaluationResult, error) { - // Convert event data to map if needed - eventMap, err := toMap(eventData) - if err != nil { - return nil, fmt.Errorf("failed to convert event data: %w", err) - } - - // Create activity builder - builder := &ActivityBuilder{ - APIGroup: spec.Resource.APIGroup, - Kind: spec.Resource.Kind, - } - - // Try each event rule in order - for i, rule := range spec.EventRules { - matched, err := cel.EvaluateEventMatch(rule.Match, eventMap) - if err != nil { - return nil, fmt.Errorf("failed to evaluate rule %d match: %w", i, err) - } - - if matched { - // Generate summary - summary, links, err := cel.EvaluateEventSummary(rule.Summary, eventMap) - if err != nil { - return nil, fmt.Errorf("failed to evaluate rule %d summary: %w", i, err) - } - - // Build the Activity - activity, err := builder.BuildFromEvent(eventMap, summary, links, resolveKind) - if err != nil { - return nil, fmt.Errorf("failed to build activity for rule %d: %w", i, err) - } - - return &EvaluationResult{ - Activity: activity, - MatchedRuleIndex: i, - MatchedRuleType: "event", - MatchedRuleName: rule.Name, - }, nil - } - } - - // No rule matched - return &EvaluationResult{ - MatchedRuleIndex: -1, - }, nil -} - -// toMap converts various input types to map[string]interface{}. -func toMap(data interface{}) (map[string]interface{}, error) { - switch v := data.(type) { - case map[string]interface{}: - return v, nil - case *map[string]interface{}: - if v == nil { - return nil, fmt.Errorf("nil map pointer") - } - return *v, nil - default: - // Try JSON marshaling as a fallback - jsonData, err := json.Marshal(data) - if err != nil { - return nil, fmt.Errorf("failed to marshal data: %w", err) - } - - var m map[string]interface{} - if err := json.Unmarshal(jsonData, &m); err != nil { - return nil, fmt.Errorf("failed to unmarshal to map: %w", err) - } - return m, nil - } -} +package processor + +import ( + "encoding/json" + "fmt" + + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// EvaluationResult contains the result of evaluating policy rules against an input. +type EvaluationResult struct { + // Activity is the generated activity, or nil if no rule matched + Activity *v1alpha1.Activity + + // MatchedRuleIndex is the index of the rule that matched, or -1 if none matched + MatchedRuleIndex int + + // MatchedRuleType is "audit" or "event" depending on which rule matched + MatchedRuleType string + + // MatchedRuleName is the name of the matched rule from the policy spec + MatchedRuleName string +} + +// EvaluateAuditRules evaluates audit rules against an audit log input. +// Returns the generated Activity if a rule matches, or nil if no rule matched. +// If resolveKind is provided, it will be used to resolve resource names to Kind in links. +func EvaluateAuditRules( + spec *v1alpha1.ActivityPolicySpec, + audit *auditv1.Event, + resolveKind KindResolver, +) (*EvaluationResult, error) { + // Convert to map for CEL evaluation + auditMap, err := toMap(audit) + if err != nil { + return nil, fmt.Errorf("failed to convert audit data: %w", err) + } + + // Create activity builder + builder := &ActivityBuilder{ + APIGroup: spec.Resource.APIGroup, + Kind: spec.Resource.Kind, + } + + // Try each audit rule in order + for i, rule := range spec.AuditRules { + matched, err := cel.EvaluateAuditMatchMap(rule.Match, auditMap) + if err != nil { + return nil, fmt.Errorf("failed to evaluate rule %d match: %w", i, err) + } + + if matched { + // Generate summary + summary, links, err := cel.EvaluateAuditSummaryMap(rule.Summary, auditMap) + if err != nil { + return nil, fmt.Errorf("failed to evaluate rule %d summary: %w", i, err) + } + + // Build the Activity + activity, err := builder.BuildFromAudit(audit, summary, links, resolveKind) + if err != nil { + return nil, fmt.Errorf("failed to build activity for rule %d: %w", i, err) + } + + return &EvaluationResult{ + Activity: activity, + MatchedRuleIndex: i, + MatchedRuleType: "audit", + MatchedRuleName: rule.Name, + }, nil + } + } + + // No rule matched + return &EvaluationResult{ + MatchedRuleIndex: -1, + }, nil +} + +// EvaluateEventRules evaluates event rules against a Kubernetes event input. +// Returns the generated Activity if a rule matches, or nil if no rule matched. +// If resolveKind is provided, it will be used to resolve resource names to Kind in links. +func EvaluateEventRules( + spec *v1alpha1.ActivityPolicySpec, + eventData interface{}, + resolveKind KindResolver, +) (*EvaluationResult, error) { + // Convert event data to map if needed + eventMap, err := toMap(eventData) + if err != nil { + return nil, fmt.Errorf("failed to convert event data: %w", err) + } + + // Create activity builder + builder := &ActivityBuilder{ + APIGroup: spec.Resource.APIGroup, + Kind: spec.Resource.Kind, + } + + // Try each event rule in order + for i, rule := range spec.EventRules { + matched, err := cel.EvaluateEventMatch(rule.Match, eventMap) + if err != nil { + return nil, fmt.Errorf("failed to evaluate rule %d match: %w", i, err) + } + + if matched { + // Generate summary + summary, links, err := cel.EvaluateEventSummary(rule.Summary, eventMap) + if err != nil { + return nil, fmt.Errorf("failed to evaluate rule %d summary: %w", i, err) + } + + // Build the Activity + activity, err := builder.BuildFromEvent(eventMap, summary, links, resolveKind) + if err != nil { + return nil, fmt.Errorf("failed to build activity for rule %d: %w", i, err) + } + + return &EvaluationResult{ + Activity: activity, + MatchedRuleIndex: i, + MatchedRuleType: "event", + MatchedRuleName: rule.Name, + }, nil + } + } + + // No rule matched + return &EvaluationResult{ + MatchedRuleIndex: -1, + }, nil +} + +// toMap converts various input types to map[string]interface{}. +func toMap(data interface{}) (map[string]interface{}, error) { + switch v := data.(type) { + case map[string]interface{}: + return v, nil + case *map[string]interface{}: + if v == nil { + return nil, fmt.Errorf("nil map pointer") + } + return *v, nil + default: + // Try JSON marshaling as a fallback + jsonData, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal data: %w", err) + } + + var m map[string]interface{} + if err := json.Unmarshal(jsonData, &m); err != nil { + return nil, fmt.Errorf("failed to unmarshal to map: %w", err) + } + return m, nil + } +} diff --git a/internal/processor/event.go b/internal/processor/event.go index d7567bff..5c8f63e1 100644 --- a/internal/processor/event.go +++ b/internal/processor/event.go @@ -1,475 +1,475 @@ -package processor - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "sync" - "time" - - "github.com/google/uuid" - "github.com/nats-io/nats.go" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/klog/v2" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// EventProcessor processes Kubernetes events from a NATS JetStream pull consumer -// and generates Activity records via ActivityPolicy event rules. -type EventProcessor struct { - js nats.JetStreamContext - streamName string - consumerName string - activityPrefix string - batchSize int - policyLookup EventPolicyLookup - workers int - dlqPublisher DLQPublisher -} - -// NewEventProcessor creates a new event processor. -// js is the JetStream context used for both consuming events and publishing activities. -// streamName is the NATS stream to consume from (e.g., "EVENTS"). -// consumerName is the durable pull consumer name. -// activityPrefix is the subject prefix for publishing generated activities. -// policyLookup is used to evaluate events against ActivityPolicy event rules. -// dlqPublisher is used to publish failed events to the dead-letter queue. -func NewEventProcessor( - js nats.JetStreamContext, - streamName string, - consumerName string, - activityPrefix string, - policyLookup EventPolicyLookup, - workers int, - batchSize int, - dlqPublisher DLQPublisher, -) *EventProcessor { - return &EventProcessor{ - js: js, - streamName: streamName, - consumerName: consumerName, - activityPrefix: activityPrefix, - policyLookup: policyLookup, - workers: workers, - batchSize: batchSize, - dlqPublisher: dlqPublisher, - } -} - -// Run starts the event processor workers and blocks until ctx is cancelled. -func (p *EventProcessor) Run(ctx context.Context) error { - klog.InfoS("Starting event processor", - "stream", p.streamName, - "consumer", p.consumerName, - "workers", p.workers, - ) - - var wg sync.WaitGroup - for i := 0; i < p.workers; i++ { - wg.Add(1) - go func(workerID int) { - defer wg.Done() - p.worker(ctx, workerID) - }(i) - } - - klog.InfoS("Event processor running", "workers", p.workers) - - <-ctx.Done() - wg.Wait() - - klog.Info("Event processor stopped") - return nil -} - -// worker consumes messages from the JetStream pull consumer and processes them. -// Messages are explicitly Acked on success and Naked on failure so that failed -// messages are redelivered (up to MaxDeliver on the consumer config). -func (p *EventProcessor) worker(ctx context.Context, id int) { - klog.V(4).InfoS("Event worker started", "worker", id) - - sub, err := p.js.PullSubscribe( - "events.>", - p.consumerName, - nats.Bind(p.streamName, p.consumerName), - ) - if err != nil { - klog.ErrorS(err, "Failed to create pull subscription for events", "worker", id) - return - } - defer sub.Unsubscribe() - - for { - select { - case <-ctx.Done(): - klog.V(4).InfoS("Event worker stopping", "worker", id) - return - default: - } - - msgs, err := sub.Fetch(p.batchSize, nats.MaxWait(5*time.Second)) - if err != nil { - if err == nats.ErrTimeout { - continue - } - klog.ErrorS(err, "Failed to fetch event messages", "worker", id) - continue - } - - for _, msg := range msgs { - if err := p.processMessage(ctx, msg); err != nil { - klog.ErrorS(err, "Failed to process event message", "worker", id) - msg.Nak() - continue - } - msg.Ack() - } - } -} - -// processMessage processes a single Kubernetes event message. -func (p *EventProcessor) processMessage(ctx context.Context, msg *nats.Msg) error { - // Keep raw payload for DLQ in case of failure - rawPayload := json.RawMessage(msg.Data) - - // Parse the Kubernetes event. - var event map[string]interface{} - if err := json.Unmarshal(msg.Data, &event); err != nil { - // Publish to DLQ - unmarshal errors are unrecoverable - // Tenant is nil for events as they don't have user context - if dlqErr := p.dlqPublisher.PublishEventFailure( - ctx, rawPayload, "", 0, -1, ErrorTypeUnmarshal, err, nil, nil, - ); dlqErr != nil { - klog.ErrorS(dlqErr, "Failed to publish to DLQ") - return fmt.Errorf("failed to unmarshal event: %w", err) - } - // Successfully published to DLQ, message can be ACKed - return nil - } - - // Extract involved object info to find matching policy. - // Kubernetes events have either "regarding" (events.k8s.io/v1) or - // "involvedObject" (core/v1) to identify the subject resource. - involvedObject := p.getInvolvedObject(event) - if involvedObject == nil { - klog.V(4).Info("Event has no involved object, skipping") - return nil - } - - apiGroup := getStringFromMap(involvedObject, "apiGroup") - // For core resources, apiVersion is "v1" with empty apiGroup. - if apiGroup == "" { - if apiVersion := getStringFromMap(involvedObject, "apiVersion"); apiVersion != "" && apiVersion != "v1" { - apiGroup = parseAPIGroup(apiVersion) - } - } - kind := getStringFromMap(involvedObject, "kind") - - if kind == "" { - klog.V(4).InfoS("Could not determine kind from event, skipping") - return nil - } - - // Build resource info for DLQ context - dlqResource := &DeadLetterResource{ - APIGroup: apiGroup, - Kind: kind, - Name: getStringFromMap(involvedObject, "name"), - Namespace: getStringFromMap(involvedObject, "namespace"), - } - - // Normalize event so CEL expressions can always use event.regarding. - normalizedEvent := p.normalizeEvent(event, involvedObject) - - // Delegate policy matching to the lookup (avoids import cycle with activityprocessor). - matched, err := p.policyLookup.MatchEvent(apiGroup, kind, normalizedEvent) - if err != nil { - // Extract policy context from error if available - errorType := ErrorTypeCELSummary // Default to summary since match errors are logged and skipped - policyName := "" - policyVersion := int64(0) - ruleIndex := -1 - - var policyErr *PolicyEvaluationError - if errors.As(err, &policyErr) { - policyName = policyErr.PolicyName - ruleIndex = policyErr.RuleIndex - } - - // Publish to DLQ - // Tenant is nil for events as they don't have user context - if dlqErr := p.dlqPublisher.PublishEventFailure( - ctx, rawPayload, policyName, policyVersion, ruleIndex, errorType, err, dlqResource, nil, - ); dlqErr != nil { - klog.ErrorS(dlqErr, "Failed to publish to DLQ, NAKing message") - return fmt.Errorf("failed to match event against policies: %w", err) - } - - klog.ErrorS(err, "Failed to match event against policies, published to DLQ", - "apiGroup", apiGroup, "kind", kind, "policy", policyName, "ruleIndex", ruleIndex) - // Successfully published to DLQ, message can be ACKed - return nil - } - - if matched == nil { - klog.V(4).InfoS("No policy matched event", - "apiGroup", apiGroup, "kind", kind) - return nil - } - - activity := p.buildActivity(event, matched, involvedObject, matched.Summary, matched.Links) - - if err := p.publishActivity(ctx, activity); err != nil { - return fmt.Errorf("failed to publish activity: %w", err) - } - - klog.V(3).InfoS("Generated activity from event", - "activity", activity.Name, - "summary", activity.Spec.Summary, - "reason", getStringFromMap(event, "reason"), - ) - - return nil -} - -// getInvolvedObject extracts the involved object from a Kubernetes event. -// Handles both v1.Event (regarding) and corev1.Event (involvedObject) formats. -func (p *EventProcessor) getInvolvedObject(event map[string]interface{}) map[string]interface{} { - // Try "regarding" first (events.k8s.io/v1). - if regarding, ok := event["regarding"].(map[string]interface{}); ok { - return regarding - } - // Fall back to "involvedObject" (v1). - if involvedObject, ok := event["involvedObject"].(map[string]interface{}); ok { - return involvedObject - } - return nil -} - -// normalizeEvent creates a copy of the event with a "regarding" field. -// This ensures CEL expressions can consistently use event.regarding regardless -// of whether the original event used "regarding" or "involvedObject". -func (p *EventProcessor) normalizeEvent(event map[string]interface{}, involvedObject map[string]interface{}) map[string]interface{} { - // If the event already has "regarding", return as-is. - if _, ok := event["regarding"]; ok { - return event - } - - // Create a shallow copy with "regarding" added. - normalized := make(map[string]interface{}, len(event)+1) - for k, v := range event { - normalized[k] = v - } - normalized["regarding"] = involvedObject - - return normalized -} - -// buildActivity constructs an Activity resource from event data. -func (p *EventProcessor) buildActivity( - event map[string]interface{}, - matched *MatchedPolicy, - involvedObject map[string]interface{}, - summary string, - links []cel.Link, -) *v1alpha1.Activity { - // Extract timestamps - try eventTime first (events.k8s.io/v1). - var timestamp time.Time - if ts := getStringFromMap(event, "eventTime"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - // Fall back to lastTimestamp or firstTimestamp. - if timestamp.IsZero() { - if ts := getStringFromMap(event, "lastTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - if timestamp.IsZero() { - if ts := getStringFromMap(event, "firstTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - // Fall back to metadata.creationTimestamp. - if timestamp.IsZero() { - if metadata, ok := event["metadata"].(map[string]interface{}); ok { - if ts := getStringFromMap(metadata, "creationTimestamp"); ts != "" { - if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { - timestamp = t - } - } - } - } - if timestamp.IsZero() { - timestamp = time.Now() - } - - // Extract resource info from involved object. - namespace := getStringFromMap(involvedObject, "namespace") - resourceName := getStringFromMap(involvedObject, "name") - resourceUID := getStringFromMap(involvedObject, "uid") - apiVersion := getStringFromMap(involvedObject, "apiVersion") - - // Resolve actor from reporting controller or source component. - actor := p.resolveEventActor(event) - - // Events from controllers are always system-initiated. - changeSource := ChangeSourceSystem - - // Extract event UID for origin tracking. - eventUID := "" - if metadata, ok := event["metadata"].(map[string]interface{}); ok { - eventUID = getStringFromMap(metadata, "uid") - } - - // Generate activity name. - activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) - - // Convert links. - var activityLinks []v1alpha1.ActivityLink - for _, link := range links { - activityLinks = append(activityLinks, v1alpha1.ActivityLink{ - Marker: link.Marker, - Resource: v1alpha1.ActivityResource{ - APIGroup: getStringFromMap(link.Resource, "apiGroup"), - Kind: getStringFromMap(link.Resource, "kind"), - Name: getStringFromMap(link.Resource, "name"), - Namespace: getStringFromMap(link.Resource, "namespace"), - UID: getStringFromMap(link.Resource, "uid"), - }, - }) - } - - return &v1alpha1.Activity{ - TypeMeta: metav1.TypeMeta{ - APIVersion: v1alpha1.SchemeGroupVersion.String(), - Kind: "Activity", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: activityName, - Namespace: namespace, - CreationTimestamp: metav1.NewTime(timestamp), - Labels: map[string]string{ - "activity.miloapis.com/origin-type": "event", - "activity.miloapis.com/change-source": changeSource, - "activity.miloapis.com/api-group": matched.APIGroup, - "activity.miloapis.com/resource-kind": matched.Kind, - "activity.miloapis.com/event-reason": getStringFromMap(event, "reason"), - }, - }, - Spec: v1alpha1.ActivitySpec{ - Summary: summary, - ChangeSource: changeSource, - Actor: actor, - Resource: v1alpha1.ActivityResource{ - APIGroup: matched.APIGroup, - APIVersion: apiVersion, - Kind: matched.Kind, - Name: resourceName, - Namespace: namespace, - UID: resourceUID, - }, - Links: activityLinks, - Tenant: v1alpha1.ActivityTenant{ - Type: "platform", - Name: "", - }, - Origin: v1alpha1.ActivityOrigin{ - Type: "event", - ID: eventUID, - }, - }, - } -} - -// resolveEventActor extracts actor information from a Kubernetes event. -// Events are generated by controllers, so we extract the reporting controller or source component. -func (p *EventProcessor) resolveEventActor(event map[string]interface{}) v1alpha1.ActivityActor { - // Try reportingController first (events.k8s.io/v1). - reportingController := getStringFromMap(event, "reportingController") - - // Fall back to source.component (v1). - if reportingController == "" { - if source, ok := event["source"].(map[string]interface{}); ok { - reportingController = getStringFromMap(source, "component") - } - } - - // Default to unknown if we can't find the controller. - if reportingController == "" { - reportingController = "unknown" - } - - return v1alpha1.ActivityActor{ - Type: ActorTypeController, - Name: reportingController, - } -} - -// publishActivity serializes and publishes an Activity to the NATS ACTIVITIES stream. -func (p *EventProcessor) publishActivity(ctx context.Context, activity *v1alpha1.Activity) error { - data, err := json.Marshal(activity) - if err != nil { - return fmt.Errorf("failed to marshal activity: %w", err) - } - - subject := p.buildActivitySubject(activity) - - // Use activity name as MsgID for NATS deduplication. - _, err = p.js.Publish(subject, data, nats.MsgId(activity.Name)) - if err != nil { - return fmt.Errorf("failed to publish activity to NATS: %w", err) - } - - return nil -} - -// buildActivitySubject returns the NATS subject for routing activities. -// Format: ....... -func (p *EventProcessor) buildActivitySubject(activity *v1alpha1.Activity) string { - prefix := p.activityPrefix - - tenantType := activity.Spec.Tenant.Type - if tenantType == "" { - tenantType = "platform" - } - tenantName := activity.Spec.Tenant.Name - if tenantName == "" { - tenantName = "_" - } - - apiGroup := activity.Spec.Resource.APIGroup - if apiGroup == "" { - apiGroup = "core" - } - - origin := activity.Spec.Origin.Type - kind := activity.Spec.Resource.Kind - namespace := activity.Spec.Resource.Namespace - if namespace == "" { - namespace = "_" - } - name := activity.Name - - return fmt.Sprintf("%s.%s.%s.%s.%s.%s.%s.%s", - prefix, tenantType, tenantName, apiGroup, origin, kind, namespace, name) -} - -// parseAPIGroup extracts the API group from an apiVersion string. -// For "apps/v1", returns "apps". For "v1", returns "". -func parseAPIGroup(apiVersion string) string { - for i := len(apiVersion) - 1; i >= 0; i-- { - if apiVersion[i] == '/' { - return apiVersion[:i] - } - } - return "" -} +package processor + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/nats-io/nats.go" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// EventProcessor processes Kubernetes events from a NATS JetStream pull consumer +// and generates Activity records via ActivityPolicy event rules. +type EventProcessor struct { + js nats.JetStreamContext + streamName string + consumerName string + activityPrefix string + batchSize int + policyLookup EventPolicyLookup + workers int + dlqPublisher DLQPublisher +} + +// NewEventProcessor creates a new event processor. +// js is the JetStream context used for both consuming events and publishing activities. +// streamName is the NATS stream to consume from (e.g., "EVENTS"). +// consumerName is the durable pull consumer name. +// activityPrefix is the subject prefix for publishing generated activities. +// policyLookup is used to evaluate events against ActivityPolicy event rules. +// dlqPublisher is used to publish failed events to the dead-letter queue. +func NewEventProcessor( + js nats.JetStreamContext, + streamName string, + consumerName string, + activityPrefix string, + policyLookup EventPolicyLookup, + workers int, + batchSize int, + dlqPublisher DLQPublisher, +) *EventProcessor { + return &EventProcessor{ + js: js, + streamName: streamName, + consumerName: consumerName, + activityPrefix: activityPrefix, + policyLookup: policyLookup, + workers: workers, + batchSize: batchSize, + dlqPublisher: dlqPublisher, + } +} + +// Run starts the event processor workers and blocks until ctx is cancelled. +func (p *EventProcessor) Run(ctx context.Context) error { + klog.InfoS("Starting event processor", + "stream", p.streamName, + "consumer", p.consumerName, + "workers", p.workers, + ) + + var wg sync.WaitGroup + for i := 0; i < p.workers; i++ { + wg.Add(1) + go func(workerID int) { + defer wg.Done() + p.worker(ctx, workerID) + }(i) + } + + klog.InfoS("Event processor running", "workers", p.workers) + + <-ctx.Done() + wg.Wait() + + klog.Info("Event processor stopped") + return nil +} + +// worker consumes messages from the JetStream pull consumer and processes them. +// Messages are explicitly Acked on success and Naked on failure so that failed +// messages are redelivered (up to MaxDeliver on the consumer config). +func (p *EventProcessor) worker(ctx context.Context, id int) { + klog.V(4).InfoS("Event worker started", "worker", id) + + sub, err := p.js.PullSubscribe( + "events.>", + p.consumerName, + nats.Bind(p.streamName, p.consumerName), + ) + if err != nil { + klog.ErrorS(err, "Failed to create pull subscription for events", "worker", id) + return + } + defer sub.Unsubscribe() + + for { + select { + case <-ctx.Done(): + klog.V(4).InfoS("Event worker stopping", "worker", id) + return + default: + } + + msgs, err := sub.Fetch(p.batchSize, nats.MaxWait(5*time.Second)) + if err != nil { + if err == nats.ErrTimeout { + continue + } + klog.ErrorS(err, "Failed to fetch event messages", "worker", id) + continue + } + + for _, msg := range msgs { + if err := p.processMessage(ctx, msg); err != nil { + klog.ErrorS(err, "Failed to process event message", "worker", id) + msg.Nak() + continue + } + msg.Ack() + } + } +} + +// processMessage processes a single Kubernetes event message. +func (p *EventProcessor) processMessage(ctx context.Context, msg *nats.Msg) error { + // Keep raw payload for DLQ in case of failure + rawPayload := json.RawMessage(msg.Data) + + // Parse the Kubernetes event. + var event map[string]interface{} + if err := json.Unmarshal(msg.Data, &event); err != nil { + // Publish to DLQ - unmarshal errors are unrecoverable + // Tenant is nil for events as they don't have user context + if dlqErr := p.dlqPublisher.PublishEventFailure( + ctx, rawPayload, "", 0, -1, ErrorTypeUnmarshal, err, nil, nil, + ); dlqErr != nil { + klog.ErrorS(dlqErr, "Failed to publish to DLQ") + return fmt.Errorf("failed to unmarshal event: %w", err) + } + // Successfully published to DLQ, message can be ACKed + return nil + } + + // Extract involved object info to find matching policy. + // Kubernetes events have either "regarding" (events.k8s.io/v1) or + // "involvedObject" (core/v1) to identify the subject resource. + involvedObject := p.getInvolvedObject(event) + if involvedObject == nil { + klog.V(4).Info("Event has no involved object, skipping") + return nil + } + + apiGroup := getStringFromMap(involvedObject, "apiGroup") + // For core resources, apiVersion is "v1" with empty apiGroup. + if apiGroup == "" { + if apiVersion := getStringFromMap(involvedObject, "apiVersion"); apiVersion != "" && apiVersion != "v1" { + apiGroup = parseAPIGroup(apiVersion) + } + } + kind := getStringFromMap(involvedObject, "kind") + + if kind == "" { + klog.V(4).InfoS("Could not determine kind from event, skipping") + return nil + } + + // Build resource info for DLQ context + dlqResource := &DeadLetterResource{ + APIGroup: apiGroup, + Kind: kind, + Name: getStringFromMap(involvedObject, "name"), + Namespace: getStringFromMap(involvedObject, "namespace"), + } + + // Normalize event so CEL expressions can always use event.regarding. + normalizedEvent := p.normalizeEvent(event, involvedObject) + + // Delegate policy matching to the lookup (avoids import cycle with activityprocessor). + matched, err := p.policyLookup.MatchEvent(apiGroup, kind, normalizedEvent) + if err != nil { + // Extract policy context from error if available + errorType := ErrorTypeCELSummary // Default to summary since match errors are logged and skipped + policyName := "" + policyVersion := int64(0) + ruleIndex := -1 + + var policyErr *PolicyEvaluationError + if errors.As(err, &policyErr) { + policyName = policyErr.PolicyName + ruleIndex = policyErr.RuleIndex + } + + // Publish to DLQ + // Tenant is nil for events as they don't have user context + if dlqErr := p.dlqPublisher.PublishEventFailure( + ctx, rawPayload, policyName, policyVersion, ruleIndex, errorType, err, dlqResource, nil, + ); dlqErr != nil { + klog.ErrorS(dlqErr, "Failed to publish to DLQ, NAKing message") + return fmt.Errorf("failed to match event against policies: %w", err) + } + + klog.ErrorS(err, "Failed to match event against policies, published to DLQ", + "apiGroup", apiGroup, "kind", kind, "policy", policyName, "ruleIndex", ruleIndex) + // Successfully published to DLQ, message can be ACKed + return nil + } + + if matched == nil { + klog.V(4).InfoS("No policy matched event", + "apiGroup", apiGroup, "kind", kind) + return nil + } + + activity := p.buildActivity(event, matched, involvedObject, matched.Summary, matched.Links) + + if err := p.publishActivity(ctx, activity); err != nil { + return fmt.Errorf("failed to publish activity: %w", err) + } + + klog.V(3).InfoS("Generated activity from event", + "activity", activity.Name, + "summary", activity.Spec.Summary, + "reason", getStringFromMap(event, "reason"), + ) + + return nil +} + +// getInvolvedObject extracts the involved object from a Kubernetes event. +// Handles both v1.Event (regarding) and corev1.Event (involvedObject) formats. +func (p *EventProcessor) getInvolvedObject(event map[string]interface{}) map[string]interface{} { + // Try "regarding" first (events.k8s.io/v1). + if regarding, ok := event["regarding"].(map[string]interface{}); ok { + return regarding + } + // Fall back to "involvedObject" (v1). + if involvedObject, ok := event["involvedObject"].(map[string]interface{}); ok { + return involvedObject + } + return nil +} + +// normalizeEvent creates a copy of the event with a "regarding" field. +// This ensures CEL expressions can consistently use event.regarding regardless +// of whether the original event used "regarding" or "involvedObject". +func (p *EventProcessor) normalizeEvent(event map[string]interface{}, involvedObject map[string]interface{}) map[string]interface{} { + // If the event already has "regarding", return as-is. + if _, ok := event["regarding"]; ok { + return event + } + + // Create a shallow copy with "regarding" added. + normalized := make(map[string]interface{}, len(event)+1) + for k, v := range event { + normalized[k] = v + } + normalized["regarding"] = involvedObject + + return normalized +} + +// buildActivity constructs an Activity resource from event data. +func (p *EventProcessor) buildActivity( + event map[string]interface{}, + matched *MatchedPolicy, + involvedObject map[string]interface{}, + summary string, + links []cel.Link, +) *v1alpha1.Activity { + // Extract timestamps - try eventTime first (events.k8s.io/v1). + var timestamp time.Time + if ts := getStringFromMap(event, "eventTime"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + timestamp = t + } + } + // Fall back to lastTimestamp or firstTimestamp. + if timestamp.IsZero() { + if ts := getStringFromMap(event, "lastTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + timestamp = t + } + } + } + if timestamp.IsZero() { + if ts := getStringFromMap(event, "firstTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + timestamp = t + } + } + } + // Fall back to metadata.creationTimestamp. + if timestamp.IsZero() { + if metadata, ok := event["metadata"].(map[string]interface{}); ok { + if ts := getStringFromMap(metadata, "creationTimestamp"); ts != "" { + if t, err := time.Parse(time.RFC3339Nano, ts); err == nil { + timestamp = t + } + } + } + } + if timestamp.IsZero() { + timestamp = time.Now() + } + + // Extract resource info from involved object. + namespace := getStringFromMap(involvedObject, "namespace") + resourceName := getStringFromMap(involvedObject, "name") + resourceUID := getStringFromMap(involvedObject, "uid") + apiVersion := getStringFromMap(involvedObject, "apiVersion") + + // Resolve actor from reporting controller or source component. + actor := p.resolveEventActor(event) + + // Events from controllers are always system-initiated. + changeSource := ChangeSourceSystem + + // Extract event UID for origin tracking. + eventUID := "" + if metadata, ok := event["metadata"].(map[string]interface{}); ok { + eventUID = getStringFromMap(metadata, "uid") + } + + // Generate activity name. + activityName := fmt.Sprintf("act-%s", uuid.New().String()[:8]) + + // Convert links. + var activityLinks []v1alpha1.ActivityLink + for _, link := range links { + activityLinks = append(activityLinks, v1alpha1.ActivityLink{ + Marker: link.Marker, + Resource: v1alpha1.ActivityResource{ + APIGroup: getStringFromMap(link.Resource, "apiGroup"), + Kind: getStringFromMap(link.Resource, "kind"), + Name: getStringFromMap(link.Resource, "name"), + Namespace: getStringFromMap(link.Resource, "namespace"), + UID: getStringFromMap(link.Resource, "uid"), + }, + }) + } + + return &v1alpha1.Activity{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.SchemeGroupVersion.String(), + Kind: "Activity", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: activityName, + Namespace: namespace, + CreationTimestamp: metav1.NewTime(timestamp), + Labels: map[string]string{ + "activity.miloapis.com/origin-type": "event", + "activity.miloapis.com/change-source": changeSource, + "activity.miloapis.com/api-group": matched.APIGroup, + "activity.miloapis.com/resource-kind": matched.Kind, + "activity.miloapis.com/event-reason": getStringFromMap(event, "reason"), + }, + }, + Spec: v1alpha1.ActivitySpec{ + Summary: summary, + ChangeSource: changeSource, + Actor: actor, + Resource: v1alpha1.ActivityResource{ + APIGroup: matched.APIGroup, + APIVersion: apiVersion, + Kind: matched.Kind, + Name: resourceName, + Namespace: namespace, + UID: resourceUID, + }, + Links: activityLinks, + Tenant: v1alpha1.ActivityTenant{ + Type: "platform", + Name: "", + }, + Origin: v1alpha1.ActivityOrigin{ + Type: "event", + ID: eventUID, + }, + }, + } +} + +// resolveEventActor extracts actor information from a Kubernetes event. +// Events are generated by controllers, so we extract the reporting controller or source component. +func (p *EventProcessor) resolveEventActor(event map[string]interface{}) v1alpha1.ActivityActor { + // Try reportingController first (events.k8s.io/v1). + reportingController := getStringFromMap(event, "reportingController") + + // Fall back to source.component (v1). + if reportingController == "" { + if source, ok := event["source"].(map[string]interface{}); ok { + reportingController = getStringFromMap(source, "component") + } + } + + // Default to unknown if we can't find the controller. + if reportingController == "" { + reportingController = "unknown" + } + + return v1alpha1.ActivityActor{ + Type: ActorTypeController, + Name: reportingController, + } +} + +// publishActivity serializes and publishes an Activity to the NATS ACTIVITIES stream. +func (p *EventProcessor) publishActivity(ctx context.Context, activity *v1alpha1.Activity) error { + data, err := json.Marshal(activity) + if err != nil { + return fmt.Errorf("failed to marshal activity: %w", err) + } + + subject := p.buildActivitySubject(activity) + + // Use activity name as MsgID for NATS deduplication. + _, err = p.js.Publish(subject, data, nats.MsgId(activity.Name)) + if err != nil { + return fmt.Errorf("failed to publish activity to NATS: %w", err) + } + + return nil +} + +// buildActivitySubject returns the NATS subject for routing activities. +// Format: ....... +func (p *EventProcessor) buildActivitySubject(activity *v1alpha1.Activity) string { + prefix := p.activityPrefix + + tenantType := activity.Spec.Tenant.Type + if tenantType == "" { + tenantType = "platform" + } + tenantName := activity.Spec.Tenant.Name + if tenantName == "" { + tenantName = "_" + } + + apiGroup := activity.Spec.Resource.APIGroup + if apiGroup == "" { + apiGroup = "core" + } + + origin := activity.Spec.Origin.Type + kind := activity.Spec.Resource.Kind + namespace := activity.Spec.Resource.Namespace + if namespace == "" { + namespace = "_" + } + name := activity.Name + + return fmt.Sprintf("%s.%s.%s.%s.%s.%s.%s.%s", + prefix, tenantType, tenantName, apiGroup, origin, kind, namespace, name) +} + +// parseAPIGroup extracts the API group from an apiVersion string. +// For "apps/v1", returns "apps". For "v1", returns "". +func parseAPIGroup(apiVersion string) string { + for i := len(apiVersion) - 1; i >= 0; i-- { + if apiVersion[i] == '/' { + return apiVersion[:i] + } + } + return "" +} diff --git a/internal/processor/event_test.go b/internal/processor/event_test.go index cfb0be68..e7875d49 100644 --- a/internal/processor/event_test.go +++ b/internal/processor/event_test.go @@ -1,330 +1,330 @@ -package processor - -import ( - "testing" -) - -func TestGetInvolvedObject(t *testing.T) { - p := &EventProcessor{} - - tests := []struct { - name string - event map[string]any - wantNil bool - wantKind string - }{ - { - name: "events.k8s.io/v1 with regarding", - event: map[string]any{ - "regarding": map[string]any{ - "kind": "Pod", - "name": "my-pod", - "namespace": "default", - "apiVersion": "v1", - }, - }, - wantNil: false, - wantKind: "Pod", - }, - { - name: "v1 with involvedObject", - event: map[string]any{ - "involvedObject": map[string]any{ - "kind": "Deployment", - "name": "my-deployment", - "namespace": "default", - "apiVersion": "apps/v1", - }, - }, - wantNil: false, - wantKind: "Deployment", - }, - { - name: "no involved object", - event: map[string]any{}, - wantNil: true, - }, - { - name: "prefers regarding over involvedObject", - event: map[string]any{ - "regarding": map[string]any{ - "kind": "Service", - }, - "involvedObject": map[string]any{ - "kind": "Pod", - }, - }, - wantNil: false, - wantKind: "Service", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := p.getInvolvedObject(tt.event) - if tt.wantNil { - if got != nil { - t.Errorf("getInvolvedObject() = %v, want nil", got) - } - return - } - if got == nil { - t.Errorf("getInvolvedObject() = nil, want non-nil") - return - } - if kind := getStringFromMap(got, "kind"); kind != tt.wantKind { - t.Errorf("getInvolvedObject() kind = %v, want %v", kind, tt.wantKind) - } - }) - } -} - -func TestParseAPIGroup(t *testing.T) { - tests := []struct { - apiVersion string - want string - }{ - {"v1", ""}, - {"apps/v1", "apps"}, - {"networking.k8s.io/v1", "networking.k8s.io"}, - {"projectcontour.io/v1", "projectcontour.io"}, - {"v1beta1", ""}, - {"batch/v1", "batch"}, - } - - for _, tt := range tests { - t.Run(tt.apiVersion, func(t *testing.T) { - if got := parseAPIGroup(tt.apiVersion); got != tt.want { - t.Errorf("parseAPIGroup(%q) = %q, want %q", tt.apiVersion, got, tt.want) - } - }) - } -} - -func TestResolveEventActor(t *testing.T) { - p := &EventProcessor{} - - tests := []struct { - name string - event map[string]any - wantType string - wantName string - }{ - { - name: "events.k8s.io/v1 with reportingController", - event: map[string]any{ - "reportingController": "deployment-controller", - }, - wantType: ActorTypeController, - wantName: "deployment-controller", - }, - { - name: "v1 with source.component", - event: map[string]any{ - "source": map[string]any{ - "component": "kubelet", - }, - }, - wantType: ActorTypeController, - wantName: "kubelet", - }, - { - name: "prefers reportingController over source", - event: map[string]any{ - "reportingController": "scheduler", - "source": map[string]any{ - "component": "kubelet", - }, - }, - wantType: ActorTypeController, - wantName: "scheduler", - }, - { - name: "no actor info", - event: map[string]any{}, - wantType: ActorTypeController, - wantName: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - actor := p.resolveEventActor(tt.event) - if actor.Type != tt.wantType { - t.Errorf("resolveEventActor() Type = %v, want %v", actor.Type, tt.wantType) - } - if actor.Name != tt.wantName { - t.Errorf("resolveEventActor() Name = %v, want %v", actor.Name, tt.wantName) - } - }) - } -} - -func TestNormalizeEvent(t *testing.T) { - p := &EventProcessor{} - - tests := []struct { - name string - event map[string]any - involvedObject map[string]any - wantRegarding bool - }{ - { - name: "event already has regarding", - event: map[string]any{ - "reason": "Scheduled", - "regarding": map[string]any{ - "kind": "Pod", - "name": "my-pod", - }, - }, - involvedObject: map[string]any{ - "kind": "Pod", - "name": "my-pod", - }, - wantRegarding: true, - }, - { - name: "event has involvedObject, should add regarding", - event: map[string]any{ - "reason": "Scheduled", - "involvedObject": map[string]any{ - "kind": "Deployment", - "name": "my-deployment", - }, - }, - involvedObject: map[string]any{ - "kind": "Deployment", - "name": "my-deployment", - }, - wantRegarding: true, - }, - { - name: "event has neither, should add regarding", - event: map[string]any{ - "reason": "Unknown", - }, - involvedObject: map[string]any{ - "kind": "Service", - "name": "my-service", - }, - wantRegarding: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - normalized := p.normalizeEvent(tt.event, tt.involvedObject) - - // Check that regarding exists - regarding, ok := normalized["regarding"].(map[string]any) - if tt.wantRegarding && !ok { - t.Error("normalizeEvent() should have 'regarding' field") - return - } - - // Verify the regarding content matches involvedObject - if regarding != nil { - if regarding["kind"] != tt.involvedObject["kind"] { - t.Errorf("regarding.kind = %v, want %v", regarding["kind"], tt.involvedObject["kind"]) - } - if regarding["name"] != tt.involvedObject["name"] { - t.Errorf("regarding.name = %v, want %v", regarding["name"], tt.involvedObject["name"]) - } - } - - // Verify original event is not modified when it had regarding - // (we return the same map reference if it already has regarding) - - // Verify other fields are preserved - if normalized["reason"] != tt.event["reason"] { - t.Errorf("reason field not preserved: got %v, want %v", normalized["reason"], tt.event["reason"]) - } - }) - } -} - -func TestBuildActivityFromEvent(t *testing.T) { - p := &EventProcessor{} - - event := map[string]any{ - "metadata": map[string]any{ - "uid": "event-123", - "creationTimestamp": "2024-01-15T10:30:00Z", - }, - "reason": "Scheduled", - "message": "Successfully assigned default/my-pod to node-1", - "reportingController": "default-scheduler", - "regarding": map[string]any{ - "kind": "Pod", - "name": "my-pod", - "namespace": "default", - "uid": "pod-456", - "apiVersion": "v1", - }, - } - - involvedObject := map[string]any{ - "kind": "Pod", - "name": "my-pod", - "namespace": "default", - "uid": "pod-456", - "apiVersion": "v1", - } - - matched := &MatchedPolicy{ - PolicyName: "core-pods", - Generation: 1, - APIGroup: "", - Kind: "Pod", - Summary: "Pod my-pod was scheduled", - } - - activity := p.buildActivity(event, matched, involvedObject, matched.Summary, nil) - - // Verify activity fields - if activity.Spec.Summary != "Pod my-pod was scheduled" { - t.Errorf("Summary = %q, want %q", activity.Spec.Summary, "Pod my-pod was scheduled") - } - - if activity.Spec.ChangeSource != ChangeSourceSystem { - t.Errorf("ChangeSource = %q, want %q", activity.Spec.ChangeSource, ChangeSourceSystem) - } - - if activity.Spec.Actor.Type != ActorTypeController { - t.Errorf("Actor.Type = %q, want %q", activity.Spec.Actor.Type, ActorTypeController) - } - - if activity.Spec.Actor.Name != "default-scheduler" { - t.Errorf("Actor.Name = %q, want %q", activity.Spec.Actor.Name, "default-scheduler") - } - - if activity.Spec.Resource.Kind != "Pod" { - t.Errorf("Resource.Kind = %q, want %q", activity.Spec.Resource.Kind, "Pod") - } - - if activity.Spec.Resource.Name != "my-pod" { - t.Errorf("Resource.Name = %q, want %q", activity.Spec.Resource.Name, "my-pod") - } - - if activity.Spec.Resource.Namespace != "default" { - t.Errorf("Resource.Namespace = %q, want %q", activity.Spec.Resource.Namespace, "default") - } - - if activity.Spec.Origin.Type != "event" { - t.Errorf("Origin.Type = %q, want %q", activity.Spec.Origin.Type, "event") - } - - if activity.Spec.Origin.ID != "event-123" { - t.Errorf("Origin.ID = %q, want %q", activity.Spec.Origin.ID, "event-123") - } - - // Verify labels - if activity.Labels["activity.miloapis.com/origin-type"] != "event" { - t.Errorf("origin-type label = %q, want %q", activity.Labels["activity.miloapis.com/origin-type"], "event") - } - - if activity.Labels["activity.miloapis.com/event-reason"] != "Scheduled" { - t.Errorf("event-reason label = %q, want %q", activity.Labels["activity.miloapis.com/event-reason"], "Scheduled") - } -} +package processor + +import ( + "testing" +) + +func TestGetInvolvedObject(t *testing.T) { + p := &EventProcessor{} + + tests := []struct { + name string + event map[string]any + wantNil bool + wantKind string + }{ + { + name: "events.k8s.io/v1 with regarding", + event: map[string]any{ + "regarding": map[string]any{ + "kind": "Pod", + "name": "my-pod", + "namespace": "default", + "apiVersion": "v1", + }, + }, + wantNil: false, + wantKind: "Pod", + }, + { + name: "v1 with involvedObject", + event: map[string]any{ + "involvedObject": map[string]any{ + "kind": "Deployment", + "name": "my-deployment", + "namespace": "default", + "apiVersion": "apps/v1", + }, + }, + wantNil: false, + wantKind: "Deployment", + }, + { + name: "no involved object", + event: map[string]any{}, + wantNil: true, + }, + { + name: "prefers regarding over involvedObject", + event: map[string]any{ + "regarding": map[string]any{ + "kind": "Service", + }, + "involvedObject": map[string]any{ + "kind": "Pod", + }, + }, + wantNil: false, + wantKind: "Service", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.getInvolvedObject(tt.event) + if tt.wantNil { + if got != nil { + t.Errorf("getInvolvedObject() = %v, want nil", got) + } + return + } + if got == nil { + t.Errorf("getInvolvedObject() = nil, want non-nil") + return + } + if kind := getStringFromMap(got, "kind"); kind != tt.wantKind { + t.Errorf("getInvolvedObject() kind = %v, want %v", kind, tt.wantKind) + } + }) + } +} + +func TestParseAPIGroup(t *testing.T) { + tests := []struct { + apiVersion string + want string + }{ + {"v1", ""}, + {"apps/v1", "apps"}, + {"networking.k8s.io/v1", "networking.k8s.io"}, + {"projectcontour.io/v1", "projectcontour.io"}, + {"v1beta1", ""}, + {"batch/v1", "batch"}, + } + + for _, tt := range tests { + t.Run(tt.apiVersion, func(t *testing.T) { + if got := parseAPIGroup(tt.apiVersion); got != tt.want { + t.Errorf("parseAPIGroup(%q) = %q, want %q", tt.apiVersion, got, tt.want) + } + }) + } +} + +func TestResolveEventActor(t *testing.T) { + p := &EventProcessor{} + + tests := []struct { + name string + event map[string]any + wantType string + wantName string + }{ + { + name: "events.k8s.io/v1 with reportingController", + event: map[string]any{ + "reportingController": "deployment-controller", + }, + wantType: ActorTypeController, + wantName: "deployment-controller", + }, + { + name: "v1 with source.component", + event: map[string]any{ + "source": map[string]any{ + "component": "kubelet", + }, + }, + wantType: ActorTypeController, + wantName: "kubelet", + }, + { + name: "prefers reportingController over source", + event: map[string]any{ + "reportingController": "scheduler", + "source": map[string]any{ + "component": "kubelet", + }, + }, + wantType: ActorTypeController, + wantName: "scheduler", + }, + { + name: "no actor info", + event: map[string]any{}, + wantType: ActorTypeController, + wantName: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actor := p.resolveEventActor(tt.event) + if actor.Type != tt.wantType { + t.Errorf("resolveEventActor() Type = %v, want %v", actor.Type, tt.wantType) + } + if actor.Name != tt.wantName { + t.Errorf("resolveEventActor() Name = %v, want %v", actor.Name, tt.wantName) + } + }) + } +} + +func TestNormalizeEvent(t *testing.T) { + p := &EventProcessor{} + + tests := []struct { + name string + event map[string]any + involvedObject map[string]any + wantRegarding bool + }{ + { + name: "event already has regarding", + event: map[string]any{ + "reason": "Scheduled", + "regarding": map[string]any{ + "kind": "Pod", + "name": "my-pod", + }, + }, + involvedObject: map[string]any{ + "kind": "Pod", + "name": "my-pod", + }, + wantRegarding: true, + }, + { + name: "event has involvedObject, should add regarding", + event: map[string]any{ + "reason": "Scheduled", + "involvedObject": map[string]any{ + "kind": "Deployment", + "name": "my-deployment", + }, + }, + involvedObject: map[string]any{ + "kind": "Deployment", + "name": "my-deployment", + }, + wantRegarding: true, + }, + { + name: "event has neither, should add regarding", + event: map[string]any{ + "reason": "Unknown", + }, + involvedObject: map[string]any{ + "kind": "Service", + "name": "my-service", + }, + wantRegarding: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + normalized := p.normalizeEvent(tt.event, tt.involvedObject) + + // Check that regarding exists + regarding, ok := normalized["regarding"].(map[string]any) + if tt.wantRegarding && !ok { + t.Error("normalizeEvent() should have 'regarding' field") + return + } + + // Verify the regarding content matches involvedObject + if regarding != nil { + if regarding["kind"] != tt.involvedObject["kind"] { + t.Errorf("regarding.kind = %v, want %v", regarding["kind"], tt.involvedObject["kind"]) + } + if regarding["name"] != tt.involvedObject["name"] { + t.Errorf("regarding.name = %v, want %v", regarding["name"], tt.involvedObject["name"]) + } + } + + // Verify original event is not modified when it had regarding + // (we return the same map reference if it already has regarding) + + // Verify other fields are preserved + if normalized["reason"] != tt.event["reason"] { + t.Errorf("reason field not preserved: got %v, want %v", normalized["reason"], tt.event["reason"]) + } + }) + } +} + +func TestBuildActivityFromEvent(t *testing.T) { + p := &EventProcessor{} + + event := map[string]any{ + "metadata": map[string]any{ + "uid": "event-123", + "creationTimestamp": "2024-01-15T10:30:00Z", + }, + "reason": "Scheduled", + "message": "Successfully assigned default/my-pod to node-1", + "reportingController": "default-scheduler", + "regarding": map[string]any{ + "kind": "Pod", + "name": "my-pod", + "namespace": "default", + "uid": "pod-456", + "apiVersion": "v1", + }, + } + + involvedObject := map[string]any{ + "kind": "Pod", + "name": "my-pod", + "namespace": "default", + "uid": "pod-456", + "apiVersion": "v1", + } + + matched := &MatchedPolicy{ + PolicyName: "core-pods", + Generation: 1, + APIGroup: "", + Kind: "Pod", + Summary: "Pod my-pod was scheduled", + } + + activity := p.buildActivity(event, matched, involvedObject, matched.Summary, nil) + + // Verify activity fields + if activity.Spec.Summary != "Pod my-pod was scheduled" { + t.Errorf("Summary = %q, want %q", activity.Spec.Summary, "Pod my-pod was scheduled") + } + + if activity.Spec.ChangeSource != ChangeSourceSystem { + t.Errorf("ChangeSource = %q, want %q", activity.Spec.ChangeSource, ChangeSourceSystem) + } + + if activity.Spec.Actor.Type != ActorTypeController { + t.Errorf("Actor.Type = %q, want %q", activity.Spec.Actor.Type, ActorTypeController) + } + + if activity.Spec.Actor.Name != "default-scheduler" { + t.Errorf("Actor.Name = %q, want %q", activity.Spec.Actor.Name, "default-scheduler") + } + + if activity.Spec.Resource.Kind != "Pod" { + t.Errorf("Resource.Kind = %q, want %q", activity.Spec.Resource.Kind, "Pod") + } + + if activity.Spec.Resource.Name != "my-pod" { + t.Errorf("Resource.Name = %q, want %q", activity.Spec.Resource.Name, "my-pod") + } + + if activity.Spec.Resource.Namespace != "default" { + t.Errorf("Resource.Namespace = %q, want %q", activity.Spec.Resource.Namespace, "default") + } + + if activity.Spec.Origin.Type != "event" { + t.Errorf("Origin.Type = %q, want %q", activity.Spec.Origin.Type, "event") + } + + if activity.Spec.Origin.ID != "event-123" { + t.Errorf("Origin.ID = %q, want %q", activity.Spec.Origin.ID, "event-123") + } + + // Verify labels + if activity.Labels["activity.miloapis.com/origin-type"] != "event" { + t.Errorf("origin-type label = %q, want %q", activity.Labels["activity.miloapis.com/origin-type"], "event") + } + + if activity.Labels["activity.miloapis.com/event-reason"] != "Scheduled" { + t.Errorf("event-reason label = %q, want %q", activity.Labels["activity.miloapis.com/event-reason"], "Scheduled") + } +} diff --git a/internal/processor/utils.go b/internal/processor/utils.go index 45850448..eb319d01 100644 --- a/internal/processor/utils.go +++ b/internal/processor/utils.go @@ -1,133 +1,133 @@ -package processor - -import ( - "fmt" - - authnv1 "k8s.io/api/authentication/v1" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// KindResolver resolves a plural resource name to its Kind using API discovery. -// Returns error if resolution fails. -type KindResolver func(apiGroup, resource string) (string, error) - -// GetNestedString extracts a string from a map, supporting nested access with multiple keys. -func GetNestedString(m map[string]any, keys ...string) string { - if m == nil || len(keys) == 0 { - return "" - } - - current := m - for i, key := range keys { - if i == len(keys)-1 { - if v, ok := current[key].(string); ok { - return v - } - return "" - } - if nested, ok := current[key].(map[string]any); ok { - current = nested - } else { - return "" - } - } - return "" -} - -// ExtractTenant extracts tenant information from user extra fields. -func ExtractTenant(user authnv1.UserInfo) v1alpha1.ActivityTenant { - tenant := v1alpha1.ActivityTenant{ - Type: "platform", - Name: "", - } - - // Look for parent type/name in extra fields - if parentType := getExtraValue(user.Extra, "iam.miloapis.com/parent-type"); parentType != "" { - tenant.Type = parentType - } - - if parentName := getExtraValue(user.Extra, "iam.miloapis.com/parent-name"); parentName != "" { - tenant.Name = parentName - } - - // Check for organization (alternative field) - if tenant.Type == "platform" { - if org := getExtraValue(user.Extra, "organization"); org != "" { - tenant.Type = "organization" - tenant.Name = org - } - } - - // Check for project (more specific than organization) - if project := getExtraValue(user.Extra, "project"); project != "" { - tenant.Type = "project" - tenant.Name = project - } - - return tenant -} - -// getExtraValue extracts the first value from a UserInfo extra field. -func getExtraValue(extra map[string]authnv1.ExtraValue, key string) string { - if values, ok := extra[key]; ok && len(values) > 0 { - return values[0] - } - return "" -} - -// ConvertLinks converts CEL links to Activity links. -// If resolveKind is provided, it will be used to resolve plural resource names to Kind. -// Returns error if kind resolution fails. -func ConvertLinks(celLinks []cel.Link, resolveKind KindResolver) ([]v1alpha1.ActivityLink, error) { - if len(celLinks) == 0 { - return nil, nil - } - - links := make([]v1alpha1.ActivityLink, len(celLinks)) - for i, l := range celLinks { - kind := getStringFromMap(l.Resource, "kind") - apiGroup := getStringFromMap(l.Resource, "apiGroup") - - // Fallback 1: if kind is empty, try to get it from the "resource" field - // This handles Kubernetes audit objectRef which has "resource" (plural) instead of "kind" - if kind == "" { - if resource := getStringFromMap(l.Resource, "resource"); resource != "" { - if resolveKind != nil { - resolvedKind, err := resolveKind(apiGroup, resource) - if err != nil { - return nil, fmt.Errorf("%w: resource %q in apiGroup %q: %v", ErrKindResolution, resource, apiGroup, err) - } - kind = resolvedKind - } - } - } - - // Fallback 2: if still empty, try to get it from the "type" field - // This handles actorRef which has {type, name} structure - if kind == "" { - kind = getStringFromMap(l.Resource, "type") - } - - links[i] = v1alpha1.ActivityLink{ - Marker: l.Marker, - Resource: v1alpha1.ActivityResource{ - APIGroup: apiGroup, - Kind: kind, - Name: getStringFromMap(l.Resource, "name"), - Namespace: getStringFromMap(l.Resource, "namespace"), - UID: getStringFromMap(l.Resource, "uid"), - }, - } - } - return links, nil -} - -// getStringFromMap safely extracts a string from a map (for cel.Link.Resource). -func getStringFromMap(m map[string]any, key string) string { - if v, ok := m[key].(string); ok { - return v - } - return "" -} +package processor + +import ( + "fmt" + + authnv1 "k8s.io/api/authentication/v1" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// KindResolver resolves a plural resource name to its Kind using API discovery. +// Returns error if resolution fails. +type KindResolver func(apiGroup, resource string) (string, error) + +// GetNestedString extracts a string from a map, supporting nested access with multiple keys. +func GetNestedString(m map[string]any, keys ...string) string { + if m == nil || len(keys) == 0 { + return "" + } + + current := m + for i, key := range keys { + if i == len(keys)-1 { + if v, ok := current[key].(string); ok { + return v + } + return "" + } + if nested, ok := current[key].(map[string]any); ok { + current = nested + } else { + return "" + } + } + return "" +} + +// ExtractTenant extracts tenant information from user extra fields. +func ExtractTenant(user authnv1.UserInfo) v1alpha1.ActivityTenant { + tenant := v1alpha1.ActivityTenant{ + Type: "platform", + Name: "", + } + + // Look for parent type/name in extra fields + if parentType := getExtraValue(user.Extra, "iam.miloapis.com/parent-type"); parentType != "" { + tenant.Type = parentType + } + + if parentName := getExtraValue(user.Extra, "iam.miloapis.com/parent-name"); parentName != "" { + tenant.Name = parentName + } + + // Check for organization (alternative field) + if tenant.Type == "platform" { + if org := getExtraValue(user.Extra, "organization"); org != "" { + tenant.Type = "organization" + tenant.Name = org + } + } + + // Check for project (more specific than organization) + if project := getExtraValue(user.Extra, "project"); project != "" { + tenant.Type = "project" + tenant.Name = project + } + + return tenant +} + +// getExtraValue extracts the first value from a UserInfo extra field. +func getExtraValue(extra map[string]authnv1.ExtraValue, key string) string { + if values, ok := extra[key]; ok && len(values) > 0 { + return values[0] + } + return "" +} + +// ConvertLinks converts CEL links to Activity links. +// If resolveKind is provided, it will be used to resolve plural resource names to Kind. +// Returns error if kind resolution fails. +func ConvertLinks(celLinks []cel.Link, resolveKind KindResolver) ([]v1alpha1.ActivityLink, error) { + if len(celLinks) == 0 { + return nil, nil + } + + links := make([]v1alpha1.ActivityLink, len(celLinks)) + for i, l := range celLinks { + kind := getStringFromMap(l.Resource, "kind") + apiGroup := getStringFromMap(l.Resource, "apiGroup") + + // Fallback 1: if kind is empty, try to get it from the "resource" field + // This handles Kubernetes audit objectRef which has "resource" (plural) instead of "kind" + if kind == "" { + if resource := getStringFromMap(l.Resource, "resource"); resource != "" { + if resolveKind != nil { + resolvedKind, err := resolveKind(apiGroup, resource) + if err != nil { + return nil, fmt.Errorf("%w: resource %q in apiGroup %q: %v", ErrKindResolution, resource, apiGroup, err) + } + kind = resolvedKind + } + } + } + + // Fallback 2: if still empty, try to get it from the "type" field + // This handles actorRef which has {type, name} structure + if kind == "" { + kind = getStringFromMap(l.Resource, "type") + } + + links[i] = v1alpha1.ActivityLink{ + Marker: l.Marker, + Resource: v1alpha1.ActivityResource{ + APIGroup: apiGroup, + Kind: kind, + Name: getStringFromMap(l.Resource, "name"), + Namespace: getStringFromMap(l.Resource, "namespace"), + UID: getStringFromMap(l.Resource, "uid"), + }, + } + } + return links, nil +} + +// getStringFromMap safely extracts a string from a map (for cel.Link.Resource). +func getStringFromMap(m map[string]any, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} diff --git a/internal/processor/utils_test.go b/internal/processor/utils_test.go index 82db5fc6..9a5850a3 100644 --- a/internal/processor/utils_test.go +++ b/internal/processor/utils_test.go @@ -1,391 +1,391 @@ -package processor - -import ( - "errors" - "fmt" - "testing" - - "go.miloapis.com/activity/internal/cel" - authnv1 "k8s.io/api/authentication/v1" -) - -func TestConvertLinks(t *testing.T) { - // Mock KindResolver for testing resource-to-kind conversion - mockResolver := func(apiGroup, resource string) (string, error) { - kinds := map[string]string{ - "deployments": "Deployment", - "services": "Service", - "pods": "Pod", - } - if kind, ok := kinds[resource]; ok { - return kind, nil - } - return "", nil - } - tests := []struct { - name string - celLinks []cel.Link - want int - wantKind string - }{ - { - name: "nil links", - celLinks: nil, - want: 0, - }, - { - name: "empty links", - celLinks: []cel.Link{}, - want: 0, - }, - { - name: "link with kind field (Kubernetes events)", - celLinks: []cel.Link{ - { - Marker: "Pod my-pod", - Resource: map[string]any{ - "kind": "Pod", - "name": "my-pod", - "namespace": "default", - "uid": "pod-123", - "apiGroup": "", - }, - }, - }, - want: 1, - wantKind: "Pod", - }, - { - name: "link with resource field (Kubernetes audit objectRef)", - celLinks: []cel.Link{ - { - Marker: "Deployment my-deployment", - Resource: map[string]any{ - "resource": "deployments", - "name": "my-deployment", - "namespace": "default", - "uid": "deploy-456", - "apiGroup": "apps", - }, - }, - }, - want: 1, - wantKind: "Deployment", - }, - { - name: "link with both kind and resource (kind wins)", - celLinks: []cel.Link{ - { - Marker: "Service my-service", - Resource: map[string]any{ - "kind": "Service", - "resource": "services", - "name": "my-service", - "namespace": "default", - }, - }, - }, - want: 1, - wantKind: "Service", - }, - { - name: "link with type field (actorRef)", - celLinks: []cel.Link{ - { - Marker: "kubernetes-admin", - Resource: map[string]any{ - "type": "user", - "name": "kubernetes-admin", - }, - }, - }, - want: 1, - wantKind: "user", - }, - { - name: "multiple links with mixed formats", - celLinks: []cel.Link{ - { - Marker: "Pod my-pod", - Resource: map[string]any{ - "kind": "Pod", - "name": "my-pod", - }, - }, - { - Marker: "Deployment my-deployment", - Resource: map[string]any{ - "resource": "deployments", - "name": "my-deployment", - }, - }, - { - Marker: "kubernetes-admin", - Resource: map[string]any{ - "type": "user", - "name": "kubernetes-admin", - }, - }, - }, - want: 3, - wantKind: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ConvertLinks(tt.celLinks, mockResolver) - if err != nil { - t.Fatalf("ConvertLinks() returned error: %v", err) - } - - if len(got) != tt.want { - t.Errorf("ConvertLinks() returned %d links, want %d", len(got), tt.want) - return - } - - if tt.want > 0 && tt.wantKind != "" { - if got[0].Resource.Kind != tt.wantKind { - t.Errorf("ConvertLinks() first link Kind = %q, want %q", got[0].Resource.Kind, tt.wantKind) - } - } - }) - } -} - -func TestConvertLinksErrorPaths(t *testing.T) { - tests := []struct { - name string - celLinks []cel.Link - resolver KindResolver - wantErr bool - wantErrIs error - }{ - { - name: "resolver returns error", - celLinks: []cel.Link{ - { - Marker: "unknown resource", - Resource: map[string]any{ - "resource": "unknowns", - "apiGroup": "test.example.com", - "name": "test-resource", - }, - }, - }, - resolver: func(apiGroup, resource string) (string, error) { - return "", fmt.Errorf("unknown resource: %s", resource) - }, - wantErr: true, - wantErrIs: ErrKindResolution, - }, - { - name: "resolver returns error for second link", - celLinks: []cel.Link{ - { - Marker: "known resource", - Resource: map[string]any{ - "kind": "Pod", // This has kind, so no resolution needed - "name": "my-pod", - }, - }, - { - Marker: "unknown resource", - Resource: map[string]any{ - "resource": "unknowns", - "apiGroup": "test.example.com", - "name": "test-resource", - }, - }, - }, - resolver: func(apiGroup, resource string) (string, error) { - return "", fmt.Errorf("discovery failed: %s", resource) - }, - wantErr: true, - wantErrIs: ErrKindResolution, - }, - { - name: "nil resolver with resource field returns no error", - celLinks: []cel.Link{ - { - Marker: "no resolver", - Resource: map[string]any{ - "resource": "deployments", - "apiGroup": "apps", - "name": "my-deployment", - }, - }, - }, - resolver: nil, // No resolver provided - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := ConvertLinks(tt.celLinks, tt.resolver) - - if tt.wantErr { - if err == nil { - t.Errorf("ConvertLinks() expected error, got nil") - return - } - if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { - t.Errorf("ConvertLinks() error = %v, want error wrapping %v", err, tt.wantErrIs) - } - return - } - - if err != nil { - t.Errorf("ConvertLinks() unexpected error: %v", err) - return - } - - if len(got) != len(tt.celLinks) { - t.Errorf("ConvertLinks() returned %d links, want %d", len(got), len(tt.celLinks)) - } - }) - } -} - -func TestExtractTenant(t *testing.T) { - tests := []struct { - name string - user authnv1.UserInfo - wantType string - wantName string - }{ - { - name: "platform (no extra fields)", - user: authnv1.UserInfo{}, - wantType: "platform", - wantName: "", - }, - { - name: "organization from parent fields", - user: authnv1.UserInfo{ - Extra: map[string]authnv1.ExtraValue{ - "iam.miloapis.com/parent-type": {"organization"}, - "iam.miloapis.com/parent-name": {"acme-corp"}, - }, - }, - wantType: "organization", - wantName: "acme-corp", - }, - { - name: "project from parent fields", - user: authnv1.UserInfo{ - Extra: map[string]authnv1.ExtraValue{ - "iam.miloapis.com/parent-type": {"project"}, - "iam.miloapis.com/parent-name": {"my-project"}, - }, - }, - wantType: "project", - wantName: "my-project", - }, - { - name: "organization from legacy field", - user: authnv1.UserInfo{ - Extra: map[string]authnv1.ExtraValue{ - "organization": {"legacy-org"}, - }, - }, - wantType: "organization", - wantName: "legacy-org", - }, - { - name: "project overrides organization", - user: authnv1.UserInfo{ - Extra: map[string]authnv1.ExtraValue{ - "organization": {"my-org"}, - "project": {"my-project"}, - }, - }, - wantType: "project", - wantName: "my-project", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ExtractTenant(tt.user) - if got.Type != tt.wantType { - t.Errorf("ExtractTenant() Type = %q, want %q", got.Type, tt.wantType) - } - if got.Name != tt.wantName { - t.Errorf("ExtractTenant() Name = %q, want %q", got.Name, tt.wantName) - } - }) - } -} - -func TestGetNestedString(t *testing.T) { - tests := []struct { - name string - m map[string]any - keys []string - want string - }{ - { - name: "single level", - m: map[string]any{"key": "value"}, - keys: []string{"key"}, - want: "value", - }, - { - name: "nested levels", - m: map[string]any{ - "user": map[string]any{ - "username": "alice", - }, - }, - keys: []string{"user", "username"}, - want: "alice", - }, - { - name: "deeply nested", - m: map[string]any{ - "audit": map[string]any{ - "objectRef": map[string]any{ - "name": "my-resource", - }, - }, - }, - keys: []string{"audit", "objectRef", "name"}, - want: "my-resource", - }, - { - name: "missing key", - m: map[string]any{"key": "value"}, - keys: []string{"missing"}, - want: "", - }, - { - name: "nil map", - m: nil, - keys: []string{"key"}, - want: "", - }, - { - name: "empty keys", - m: map[string]any{"key": "value"}, - keys: []string{}, - want: "", - }, - { - name: "non-string value", - m: map[string]any{"key": 123}, - keys: []string{"key"}, - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GetNestedString(tt.m, tt.keys...) - if got != tt.want { - t.Errorf("GetNestedString() = %q, want %q", got, tt.want) - } - }) - } -} +package processor + +import ( + "errors" + "fmt" + "testing" + + "go.miloapis.com/activity/internal/cel" + authnv1 "k8s.io/api/authentication/v1" +) + +func TestConvertLinks(t *testing.T) { + // Mock KindResolver for testing resource-to-kind conversion + mockResolver := func(apiGroup, resource string) (string, error) { + kinds := map[string]string{ + "deployments": "Deployment", + "services": "Service", + "pods": "Pod", + } + if kind, ok := kinds[resource]; ok { + return kind, nil + } + return "", nil + } + tests := []struct { + name string + celLinks []cel.Link + want int + wantKind string + }{ + { + name: "nil links", + celLinks: nil, + want: 0, + }, + { + name: "empty links", + celLinks: []cel.Link{}, + want: 0, + }, + { + name: "link with kind field (Kubernetes events)", + celLinks: []cel.Link{ + { + Marker: "Pod my-pod", + Resource: map[string]any{ + "kind": "Pod", + "name": "my-pod", + "namespace": "default", + "uid": "pod-123", + "apiGroup": "", + }, + }, + }, + want: 1, + wantKind: "Pod", + }, + { + name: "link with resource field (Kubernetes audit objectRef)", + celLinks: []cel.Link{ + { + Marker: "Deployment my-deployment", + Resource: map[string]any{ + "resource": "deployments", + "name": "my-deployment", + "namespace": "default", + "uid": "deploy-456", + "apiGroup": "apps", + }, + }, + }, + want: 1, + wantKind: "Deployment", + }, + { + name: "link with both kind and resource (kind wins)", + celLinks: []cel.Link{ + { + Marker: "Service my-service", + Resource: map[string]any{ + "kind": "Service", + "resource": "services", + "name": "my-service", + "namespace": "default", + }, + }, + }, + want: 1, + wantKind: "Service", + }, + { + name: "link with type field (actorRef)", + celLinks: []cel.Link{ + { + Marker: "kubernetes-admin", + Resource: map[string]any{ + "type": "user", + "name": "kubernetes-admin", + }, + }, + }, + want: 1, + wantKind: "user", + }, + { + name: "multiple links with mixed formats", + celLinks: []cel.Link{ + { + Marker: "Pod my-pod", + Resource: map[string]any{ + "kind": "Pod", + "name": "my-pod", + }, + }, + { + Marker: "Deployment my-deployment", + Resource: map[string]any{ + "resource": "deployments", + "name": "my-deployment", + }, + }, + { + Marker: "kubernetes-admin", + Resource: map[string]any{ + "type": "user", + "name": "kubernetes-admin", + }, + }, + }, + want: 3, + wantKind: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertLinks(tt.celLinks, mockResolver) + if err != nil { + t.Fatalf("ConvertLinks() returned error: %v", err) + } + + if len(got) != tt.want { + t.Errorf("ConvertLinks() returned %d links, want %d", len(got), tt.want) + return + } + + if tt.want > 0 && tt.wantKind != "" { + if got[0].Resource.Kind != tt.wantKind { + t.Errorf("ConvertLinks() first link Kind = %q, want %q", got[0].Resource.Kind, tt.wantKind) + } + } + }) + } +} + +func TestConvertLinksErrorPaths(t *testing.T) { + tests := []struct { + name string + celLinks []cel.Link + resolver KindResolver + wantErr bool + wantErrIs error + }{ + { + name: "resolver returns error", + celLinks: []cel.Link{ + { + Marker: "unknown resource", + Resource: map[string]any{ + "resource": "unknowns", + "apiGroup": "test.example.com", + "name": "test-resource", + }, + }, + }, + resolver: func(apiGroup, resource string) (string, error) { + return "", fmt.Errorf("unknown resource: %s", resource) + }, + wantErr: true, + wantErrIs: ErrKindResolution, + }, + { + name: "resolver returns error for second link", + celLinks: []cel.Link{ + { + Marker: "known resource", + Resource: map[string]any{ + "kind": "Pod", // This has kind, so no resolution needed + "name": "my-pod", + }, + }, + { + Marker: "unknown resource", + Resource: map[string]any{ + "resource": "unknowns", + "apiGroup": "test.example.com", + "name": "test-resource", + }, + }, + }, + resolver: func(apiGroup, resource string) (string, error) { + return "", fmt.Errorf("discovery failed: %s", resource) + }, + wantErr: true, + wantErrIs: ErrKindResolution, + }, + { + name: "nil resolver with resource field returns no error", + celLinks: []cel.Link{ + { + Marker: "no resolver", + Resource: map[string]any{ + "resource": "deployments", + "apiGroup": "apps", + "name": "my-deployment", + }, + }, + }, + resolver: nil, // No resolver provided + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConvertLinks(tt.celLinks, tt.resolver) + + if tt.wantErr { + if err == nil { + t.Errorf("ConvertLinks() expected error, got nil") + return + } + if tt.wantErrIs != nil && !errors.Is(err, tt.wantErrIs) { + t.Errorf("ConvertLinks() error = %v, want error wrapping %v", err, tt.wantErrIs) + } + return + } + + if err != nil { + t.Errorf("ConvertLinks() unexpected error: %v", err) + return + } + + if len(got) != len(tt.celLinks) { + t.Errorf("ConvertLinks() returned %d links, want %d", len(got), len(tt.celLinks)) + } + }) + } +} + +func TestExtractTenant(t *testing.T) { + tests := []struct { + name string + user authnv1.UserInfo + wantType string + wantName string + }{ + { + name: "platform (no extra fields)", + user: authnv1.UserInfo{}, + wantType: "platform", + wantName: "", + }, + { + name: "organization from parent fields", + user: authnv1.UserInfo{ + Extra: map[string]authnv1.ExtraValue{ + "iam.miloapis.com/parent-type": {"organization"}, + "iam.miloapis.com/parent-name": {"acme-corp"}, + }, + }, + wantType: "organization", + wantName: "acme-corp", + }, + { + name: "project from parent fields", + user: authnv1.UserInfo{ + Extra: map[string]authnv1.ExtraValue{ + "iam.miloapis.com/parent-type": {"project"}, + "iam.miloapis.com/parent-name": {"my-project"}, + }, + }, + wantType: "project", + wantName: "my-project", + }, + { + name: "organization from legacy field", + user: authnv1.UserInfo{ + Extra: map[string]authnv1.ExtraValue{ + "organization": {"legacy-org"}, + }, + }, + wantType: "organization", + wantName: "legacy-org", + }, + { + name: "project overrides organization", + user: authnv1.UserInfo{ + Extra: map[string]authnv1.ExtraValue{ + "organization": {"my-org"}, + "project": {"my-project"}, + }, + }, + wantType: "project", + wantName: "my-project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractTenant(tt.user) + if got.Type != tt.wantType { + t.Errorf("ExtractTenant() Type = %q, want %q", got.Type, tt.wantType) + } + if got.Name != tt.wantName { + t.Errorf("ExtractTenant() Name = %q, want %q", got.Name, tt.wantName) + } + }) + } +} + +func TestGetNestedString(t *testing.T) { + tests := []struct { + name string + m map[string]any + keys []string + want string + }{ + { + name: "single level", + m: map[string]any{"key": "value"}, + keys: []string{"key"}, + want: "value", + }, + { + name: "nested levels", + m: map[string]any{ + "user": map[string]any{ + "username": "alice", + }, + }, + keys: []string{"user", "username"}, + want: "alice", + }, + { + name: "deeply nested", + m: map[string]any{ + "audit": map[string]any{ + "objectRef": map[string]any{ + "name": "my-resource", + }, + }, + }, + keys: []string{"audit", "objectRef", "name"}, + want: "my-resource", + }, + { + name: "missing key", + m: map[string]any{"key": "value"}, + keys: []string{"missing"}, + want: "", + }, + { + name: "nil map", + m: nil, + keys: []string{"key"}, + want: "", + }, + { + name: "empty keys", + m: map[string]any{"key": "value"}, + keys: []string{}, + want: "", + }, + { + name: "non-string value", + m: map[string]any{"key": 123}, + keys: []string{"key"}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := GetNestedString(tt.m, tt.keys...) + if got != tt.want { + t.Errorf("GetNestedString() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/registry/activity/auditlog/storage_test.go b/internal/registry/activity/auditlog/storage_test.go index 5e95fc64..b311da29 100644 --- a/internal/registry/activity/auditlog/storage_test.go +++ b/internal/registry/activity/auditlog/storage_test.go @@ -1,949 +1,949 @@ -package auditlog - -import ( - "context" - "fmt" - "strings" - "testing" - "time" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/apiserver/pkg/endpoints/request" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - - "go.miloapis.com/activity/internal/registry/scope" - "go.miloapis.com/activity/internal/storage" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// mockStorageInterface is a test double for StorageInterface -type mockStorageInterface struct { - queryFunc func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) - maxQueryWindow time.Duration - maxPageSize int32 -} - -func (m *mockStorageInterface) QueryAuditLogs(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - if m.queryFunc != nil { - return m.queryFunc(ctx, spec, scope) - } - return &storage.QueryResult{ - Events: []auditv1.Event{}, - Continue: "", - }, nil -} - -func (m *mockStorageInterface) GetMaxQueryWindow() time.Duration { - return m.maxQueryWindow -} - -func (m *mockStorageInterface) GetMaxPageSize() int32 { - return m.maxPageSize -} - -// TestQueryStorage_RESTInterface verifies the REST interface contracts -func TestQueryStorage_RESTInterface(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - } - qs := &QueryStorage{storage: mockStorage} - - t.Run("New returns empty AuditLogQuery", func(t *testing.T) { - obj := qs.New() - query, ok := obj.(*v1alpha1.AuditLogQuery) - if !ok { - t.Errorf("New() returned %T, want *v1alpha1.AuditLogQuery", obj) - } - if query == nil { - t.Error("New() returned nil") - } - }) - - t.Run("NamespaceScoped returns false", func(t *testing.T) { - if qs.NamespaceScoped() { - t.Error("NamespaceScoped() = true, want false") - } - }) - - t.Run("GetSingularName returns correct value", func(t *testing.T) { - want := "auditlogquery" - if got := qs.GetSingularName(); got != want { - t.Errorf("GetSingularName() = %q, want %q", got, want) - } - }) - - t.Run("Destroy doesn't panic", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("Destroy() panicked: %v", r) - } - }() - qs.Destroy() - }) -} - -// TestQueryStorage_Create_Success tests successful query execution through the public API -func TestQueryStorage_Create_Success(t *testing.T) { - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - - mockEvents := []auditv1.Event{ - { - AuditID: "test-audit-1", - Verb: "delete", - }, - { - AuditID: "test-audit-2", - Verb: "create", - }, - } - - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - return &storage.QueryResult{ - Events: mockEvents, - Continue: "next-page-token", - }, nil - }, - } - qs := &QueryStorage{storage: mockStorage} - - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - Name: "test-query", - }, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: yesterday.Format(time.RFC3339), - EndTime: now.Format(time.RFC3339), - Filter: "verb == 'delete'", - Limit: 100, - }, - } - - // Create context with authenticated user - testUser := &user.DefaultInfo{ - Name: "test-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Organization"}, - scope.ParentNameExtraKey: {"test-org"}, - }, - } - ctx := request.WithUser(context.Background(), testUser) - - result, err := qs.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - resultQuery, ok := result.(*v1alpha1.AuditLogQuery) - if !ok { - t.Fatalf("Create() returned %T, want *v1alpha1.AuditLogQuery", result) - } - - // Verify results were populated - if len(resultQuery.Status.Results) != len(mockEvents) { - t.Errorf("Status.Results has %d events, want %d", len(resultQuery.Status.Results), len(mockEvents)) - } - - if resultQuery.Status.Continue != "next-page-token" { - t.Errorf("Status.Continue = %q, want %q", resultQuery.Status.Continue, "next-page-token") - } - - // Verify effective timestamps are populated - if resultQuery.Status.EffectiveStartTime == "" { - t.Error("Status.EffectiveStartTime is empty, want populated timestamp") - } - if resultQuery.Status.EffectiveEndTime == "" { - t.Error("Status.EffectiveEndTime is empty, want populated timestamp") - } - - // Verify effective timestamps match the input (since we used absolute timestamps) - if resultQuery.Status.EffectiveStartTime != query.Spec.StartTime { - t.Errorf("Status.EffectiveStartTime = %q, want %q", resultQuery.Status.EffectiveStartTime, query.Spec.StartTime) - } - if resultQuery.Status.EffectiveEndTime != query.Spec.EndTime { - t.Errorf("Status.EffectiveEndTime = %q, want %q", resultQuery.Status.EffectiveEndTime, query.Spec.EndTime) - } -} - -// TestQueryStorage_Create_ScopeExtraction tests that scope is properly extracted from user context -func TestQueryStorage_Create_ScopeExtraction(t *testing.T) { - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - - var capturedScope storage.ScopeContext - - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - capturedScope = scope - return &storage.QueryResult{Events: []auditv1.Event{}}, nil - }, - } - qs := &QueryStorage{storage: mockStorage} - - tests := []struct { - name string - user user.Info - wantType string - wantName string - }{ - { - name: "organization scope", - user: &user.DefaultInfo{ - Name: "org-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Organization"}, - scope.ParentNameExtraKey: {"acme-corp"}, - }, - }, - wantType: "organization", - wantName: "acme-corp", - }, - { - name: "project scope", - user: &user.DefaultInfo{ - Name: "project-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Project"}, - scope.ParentNameExtraKey: {"backend-api"}, - }, - }, - wantType: "project", - wantName: "backend-api", - }, - { - name: "user scope", - user: &user.DefaultInfo{ - Name: "user-scoped", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"User"}, - scope.ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, - }, - }, - wantType: "user", - wantName: "550e8400-e29b-41d4-a716-446655440000", - }, - { - name: "platform scope (no extra)", - user: &user.DefaultInfo{ - Name: "admin-user", - }, - wantType: "platform", - wantName: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - capturedScope = storage.ScopeContext{Type: "", Name: ""} // Reset - - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "scope-test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: yesterday.Format(time.RFC3339), - EndTime: now.Format(time.RFC3339), - Limit: 10, - }, - } - - ctx := request.WithUser(context.Background(), tt.user) - _, err := qs.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - if capturedScope.Type != tt.wantType { - t.Errorf("Scope.Type = %q, want %q", capturedScope.Type, tt.wantType) - } - if capturedScope.Name != tt.wantName { - t.Errorf("Scope.Name = %q, want %q", capturedScope.Name, tt.wantName) - } - }) - } -} - -// TestQueryStorage_Create_ValidationErrors tests validation errors through the public API -func TestQueryStorage_Create_ValidationErrors(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - } - qs := &QueryStorage{storage: mockStorage} - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - query *v1alpha1.AuditLogQuery - wantError string - }{ - { - name: "missing startTime", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - EndTime: "now", - }, - }, - wantError: "must specify a start time", - }, - { - name: "missing endTime", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-7d", - }, - }, - wantError: "must specify an end time", - }, - { - name: "invalid startTime format", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "2024/01/01", - EndTime: "now", - }, - }, - wantError: "invalid time format", - }, - { - name: "invalid endTime format", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-7d", - EndTime: "invalid", - }, - }, - wantError: "invalid time format", - }, - { - name: "endTime before startTime", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "2024-01-02T00:00:00Z", - EndTime: "2024-01-01T00:00:00Z", - }, - }, - wantError: "endTime must be after startTime", - }, - { - name: "same startTime and endTime", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "2024-01-01T00:00:00Z", - EndTime: "2024-01-01T00:00:00Z", - }, - }, - wantError: "endTime must be after startTime", - }, - { - name: "time range exceeds maximum", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "2024-01-01T00:00:00Z", - EndTime: "2024-01-09T00:00:00Z", // 8 days > 7 day max - }, - }, - wantError: "time range of", - }, - { - name: "negative limit", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Limit: -10, - }, - }, - wantError: "limit must be non-negative", - }, - { - name: "limit exceeds maximum", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Limit: 2000, // > 1000 max - }, - }, - wantError: "limit of 2000 exceeds maximum of 1000", - }, - { - name: "invalid cursor format", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Limit: 100, - Continue: "invalid-cursor!@#$", - }, - }, - wantError: "cannot decode pagination cursor", - }, - { - name: "invalid CEL filter syntax", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: "verb === 'delete'", // invalid syntax (triple equals) - }, - }, - wantError: "Invalid filter", // Friendly error message - }, - { - name: "invalid CEL field access", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: "nonexistentField == 'value'", - }, - }, - wantError: "undeclared reference", - }, - { - name: "CEL field without dot notation", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: "user == 'admin'", // should be user.username - }, - }, - wantError: "found no matching overload", - }, - { - name: "CEL filter with wrong return type", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: "verb", // returns string, not boolean - }, - }, - wantError: "filter expression must return a boolean", - }, - { - name: "invalid field name on objectRef (plural instead of singular)", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: `objectRef.resources == "domains"`, // should be objectRef.resource (singular) - }, - }, - wantError: "field 'objectRef.resources' is not available for filtering", - }, - { - name: "invalid field name on user", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: `user.name == "admin"`, // should be user.username - }, - }, - wantError: "field 'user.name' is not available for filtering", - }, - { - name: "invalid field name on responseStatus", - query: &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: "now", - Filter: `responseStatus.status == 200`, // should be responseStatus.code - }, - }, - wantError: "field 'responseStatus.status' is not available for filtering", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := qs.Create(ctx, tt.query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - // Should return Invalid status error - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != 422 { - t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) - } - - if string(statusErr.ErrStatus.Reason) != "Invalid" { - t.Errorf("Reason = %q, want %q", statusErr.ErrStatus.Reason, "Invalid") - } - - errStr := err.Error() - if !strings.Contains(errStr, tt.wantError) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) - } - }) - } -} - -// TestQueryStorage_Create_StorageErrors tests error handling from the storage layer. -// CEL validation errors are now caught at the API layer, so storage errors should -// only be runtime database errors. -func TestQueryStorage_Create_StorageErrors(t *testing.T) { - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - - tests := []struct { - name string - storageError error - wantStatus int32 - wantContains string - }{ - { - name: "database connection error", - storageError: fmt.Errorf("connection failed"), - wantStatus: 503, - wantContains: "Failed to execute query", - }, - { - name: "query timeout", - storageError: fmt.Errorf("context deadline exceeded"), - wantStatus: 503, - wantContains: "Failed to execute query", - }, - { - name: "clickhouse error", - storageError: fmt.Errorf("clickhouse: table not found"), - wantStatus: 503, - wantContains: "Failed to execute query", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - return nil, tt.storageError - }, - } - qs := &QueryStorage{storage: mockStorage} - - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: yesterday.Format(time.RFC3339), - EndTime: now.Format(time.RFC3339), - Filter: "verb == 'delete'", - Limit: 100, - }, - } - - _, err := qs.Create(ctx, query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != tt.wantStatus { - t.Errorf("Status code = %d, want %d", statusErr.ErrStatus.Code, tt.wantStatus) - } - - errStr := err.Error() - if !strings.Contains(errStr, tt.wantContains) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantContains) - } - }) - } -} - -// TestQueryStorage_Create_NoUserContext tests that missing user context returns error -func TestQueryStorage_Create_NoUserContext(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - } - qs := &QueryStorage{storage: mockStorage} - - now := time.Now() - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: "now-1h", - EndTime: now.Format(time.RFC3339), - }, - } - - // Create without user context - _, err := qs.Create(context.Background(), query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != 500 { - t.Errorf("Status code = %d, want 500", statusErr.ErrStatus.Code) - } -} - -// TestQueryStorage_Create_WrongObjectType tests that non-AuditLogQuery objects are rejected -func TestQueryStorage_Create_WrongObjectType(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - } - qs := &QueryStorage{storage: mockStorage} - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - // Pass wrong object type - wrongObj := &v1alpha1.ActivityPolicy{} - _, err := qs.Create(ctx, wrongObj, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - if !strings.Contains(err.Error(), "not an AuditLogQuery") { - t.Errorf("Error message %q should contain 'not an AuditLogQuery'", err.Error()) - } -} - -// TestQueryStorage_Create_CursorValidation tests cursor validation at the API layer -func TestQueryStorage_Create_CursorValidation(t *testing.T) { - now := time.Now() - yesterday := now.Add(-24 * time.Hour) - - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - } - qs := &QueryStorage{storage: mockStorage} - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - cursor string - wantError string - }{ - { - name: "invalid base64 cursor", - cursor: "not-valid-base64!@#$", - wantError: "cannot decode pagination cursor", - }, - { - name: "invalid JSON cursor", - cursor: "aW52YWxpZGpzb24=", // base64("invalidjson") - wantError: "cursor format is invalid", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: yesterday.Format(time.RFC3339), - EndTime: now.Format(time.RFC3339), - Limit: 100, - Continue: tt.cursor, - }, - } - - _, err := qs.Create(ctx, query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - // Should return Invalid status error (422) - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != 422 { - t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) - } - - if string(statusErr.ErrStatus.Reason) != "Invalid" { - t.Errorf("Reason = %q, want %q", statusErr.ErrStatus.Reason, "Invalid") - } - - errStr := err.Error() - if !strings.Contains(errStr, tt.wantError) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) - } - - // Verify the error is on the continue field - if !strings.Contains(errStr, "continue") { - t.Errorf("Error should reference 'continue' field, got: %s", errStr) - } - }) - } -} - -// TestQueryStorage_Create_RelativeTimeFormats tests various relative time formats -func TestQueryStorage_Create_RelativeTimeFormats(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 7 * 24 * time.Hour, - maxPageSize: 1000, - queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - return &storage.QueryResult{Events: []auditv1.Event{}}, nil - }, - } - qs := &QueryStorage{storage: mockStorage} - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - startTime string - endTime string - wantValid bool - }{ - { - name: "relative times with days", - startTime: "now-6d", - endTime: "now", - wantValid: true, - }, - { - name: "relative times with hours", - startTime: "now-24h", - endTime: "now", - wantValid: true, - }, - { - name: "relative times with minutes", - startTime: "now-30m", - endTime: "now", - wantValid: true, - }, - { - name: "mixed RFC3339 and relative", - startTime: "now-48h", - endTime: "now", - wantValid: true, - }, - { - name: "both RFC3339", - startTime: "2024-01-01T00:00:00Z", - endTime: "2024-01-02T00:00:00Z", - wantValid: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: tt.startTime, - EndTime: tt.endTime, - Limit: 100, - }, - } - - _, err := qs.Create(ctx, query, nil, nil) - - if tt.wantValid && err != nil { - t.Errorf("Create() error = %v, want nil", err) - } - if !tt.wantValid && err == nil { - t.Error("Create() error = nil, want error") - } - }) - } -} - -// TestQueryStorage_Create_EffectiveTimestamps tests that effective timestamps are correctly populated -func TestQueryStorage_Create_EffectiveTimestamps(t *testing.T) { - mockStorage := &mockStorageInterface{ - maxQueryWindow: 30 * 24 * time.Hour, - maxPageSize: 1000, - queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { - return &storage.QueryResult{Events: []auditv1.Event{}}, nil - }, - } - qs := &QueryStorage{storage: mockStorage} - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - startTime string - endTime string - checkFunc func(t *testing.T, query *v1alpha1.AuditLogQuery) - }{ - { - name: "relative times - both relative", - startTime: "now-7d", - endTime: "now", - checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { - // Should have RFC3339 formatted timestamps - if query.Status.EffectiveStartTime == "" { - t.Error("EffectiveStartTime is empty") - } - if query.Status.EffectiveEndTime == "" { - t.Error("EffectiveEndTime is empty") - } - - // Parse to verify they're valid RFC3339 - startTime, err := time.Parse(time.RFC3339, query.Status.EffectiveStartTime) - if err != nil { - t.Errorf("EffectiveStartTime is not valid RFC3339: %v", err) - } - endTime, err := time.Parse(time.RFC3339, query.Status.EffectiveEndTime) - if err != nil { - t.Errorf("EffectiveEndTime is not valid RFC3339: %v", err) - } - - // Verify the time range is approximately 7 days. - // Tolerance accounts for DST transitions — AddDate preserves - // wall-clock time, so elapsed duration can differ by up to 1 hour - // when a DST boundary falls within the 7-day window. - duration := endTime.Sub(startTime) - expectedDuration := 7 * 24 * time.Hour - tolerance := time.Hour + time.Second - if duration < expectedDuration-tolerance || duration > expectedDuration+tolerance { - t.Errorf("Time range = %v, want ~%v (±%v for DST)", duration, expectedDuration, tolerance) - } - - // Verify endTime is very close to now (within 1 second) - now := time.Now() - timeDiff := now.Sub(endTime) - if timeDiff < 0 { - timeDiff = -timeDiff - } - if timeDiff > time.Second { - t.Errorf("EffectiveEndTime is %v away from now, expected < 1s", timeDiff) - } - }, - }, - { - name: "absolute times - both absolute", - startTime: "2024-01-01T00:00:00Z", - endTime: "2024-01-02T00:00:00Z", - checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { - // Should match exactly - if query.Status.EffectiveStartTime != "2024-01-01T00:00:00Z" { - t.Errorf("EffectiveStartTime = %q, want %q", query.Status.EffectiveStartTime, "2024-01-01T00:00:00Z") - } - if query.Status.EffectiveEndTime != "2024-01-02T00:00:00Z" { - t.Errorf("EffectiveEndTime = %q, want %q", query.Status.EffectiveEndTime, "2024-01-02T00:00:00Z") - } - }, - }, - { - name: "mixed times - relative start, relative end", - startTime: "now-48h", - endTime: "now-24h", - checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { - // EffectiveStartTime should be RFC3339 formatted (from relative time) - if query.Status.EffectiveStartTime == "" { - t.Error("EffectiveStartTime is empty") - } - startTime, err := time.Parse(time.RFC3339, query.Status.EffectiveStartTime) - if err != nil { - t.Errorf("EffectiveStartTime is not valid RFC3339: %v", err) - } - - // Should be approximately 48 hours before now - now := time.Now() - duration := now.Sub(startTime) - expectedDuration := 48 * time.Hour - if duration < expectedDuration-time.Second || duration > expectedDuration+time.Second { - t.Errorf("Time from start to now = %v, want ~%v", duration, expectedDuration) - } - - // EffectiveEndTime should be RFC3339 formatted (from relative time) - if query.Status.EffectiveEndTime == "" { - t.Error("EffectiveEndTime is empty") - } - endTime, err := time.Parse(time.RFC3339, query.Status.EffectiveEndTime) - if err != nil { - t.Errorf("EffectiveEndTime is not valid RFC3339: %v", err) - } - - // Should be approximately 24 hours before now - duration = now.Sub(endTime) - expectedDuration = 24 * time.Hour - if duration < expectedDuration-time.Second || duration > expectedDuration+time.Second { - t.Errorf("Time from end to now = %v, want ~%v", duration, expectedDuration) - } - - // Verify startTime is before endTime - if !startTime.Before(endTime) { - t.Errorf("startTime %v should be before endTime %v", startTime, endTime) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: tt.startTime, - EndTime: tt.endTime, - }, - } - - result, err := qs.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - resultQuery := result.(*v1alpha1.AuditLogQuery) - tt.checkFunc(t, resultQuery) - }) - } -} +package auditlog + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + + "go.miloapis.com/activity/internal/registry/scope" + "go.miloapis.com/activity/internal/storage" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// mockStorageInterface is a test double for StorageInterface +type mockStorageInterface struct { + queryFunc func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) + maxQueryWindow time.Duration + maxPageSize int32 +} + +func (m *mockStorageInterface) QueryAuditLogs(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, spec, scope) + } + return &storage.QueryResult{ + Events: []auditv1.Event{}, + Continue: "", + }, nil +} + +func (m *mockStorageInterface) GetMaxQueryWindow() time.Duration { + return m.maxQueryWindow +} + +func (m *mockStorageInterface) GetMaxPageSize() int32 { + return m.maxPageSize +} + +// TestQueryStorage_RESTInterface verifies the REST interface contracts +func TestQueryStorage_RESTInterface(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + } + qs := &QueryStorage{storage: mockStorage} + + t.Run("New returns empty AuditLogQuery", func(t *testing.T) { + obj := qs.New() + query, ok := obj.(*v1alpha1.AuditLogQuery) + if !ok { + t.Errorf("New() returned %T, want *v1alpha1.AuditLogQuery", obj) + } + if query == nil { + t.Error("New() returned nil") + } + }) + + t.Run("NamespaceScoped returns false", func(t *testing.T) { + if qs.NamespaceScoped() { + t.Error("NamespaceScoped() = true, want false") + } + }) + + t.Run("GetSingularName returns correct value", func(t *testing.T) { + want := "auditlogquery" + if got := qs.GetSingularName(); got != want { + t.Errorf("GetSingularName() = %q, want %q", got, want) + } + }) + + t.Run("Destroy doesn't panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Destroy() panicked: %v", r) + } + }() + qs.Destroy() + }) +} + +// TestQueryStorage_Create_Success tests successful query execution through the public API +func TestQueryStorage_Create_Success(t *testing.T) { + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + mockEvents := []auditv1.Event{ + { + AuditID: "test-audit-1", + Verb: "delete", + }, + { + AuditID: "test-audit-2", + Verb: "create", + }, + } + + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + return &storage.QueryResult{ + Events: mockEvents, + Continue: "next-page-token", + }, nil + }, + } + qs := &QueryStorage{storage: mockStorage} + + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-query", + }, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: yesterday.Format(time.RFC3339), + EndTime: now.Format(time.RFC3339), + Filter: "verb == 'delete'", + Limit: 100, + }, + } + + // Create context with authenticated user + testUser := &user.DefaultInfo{ + Name: "test-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Organization"}, + scope.ParentNameExtraKey: {"test-org"}, + }, + } + ctx := request.WithUser(context.Background(), testUser) + + result, err := qs.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + resultQuery, ok := result.(*v1alpha1.AuditLogQuery) + if !ok { + t.Fatalf("Create() returned %T, want *v1alpha1.AuditLogQuery", result) + } + + // Verify results were populated + if len(resultQuery.Status.Results) != len(mockEvents) { + t.Errorf("Status.Results has %d events, want %d", len(resultQuery.Status.Results), len(mockEvents)) + } + + if resultQuery.Status.Continue != "next-page-token" { + t.Errorf("Status.Continue = %q, want %q", resultQuery.Status.Continue, "next-page-token") + } + + // Verify effective timestamps are populated + if resultQuery.Status.EffectiveStartTime == "" { + t.Error("Status.EffectiveStartTime is empty, want populated timestamp") + } + if resultQuery.Status.EffectiveEndTime == "" { + t.Error("Status.EffectiveEndTime is empty, want populated timestamp") + } + + // Verify effective timestamps match the input (since we used absolute timestamps) + if resultQuery.Status.EffectiveStartTime != query.Spec.StartTime { + t.Errorf("Status.EffectiveStartTime = %q, want %q", resultQuery.Status.EffectiveStartTime, query.Spec.StartTime) + } + if resultQuery.Status.EffectiveEndTime != query.Spec.EndTime { + t.Errorf("Status.EffectiveEndTime = %q, want %q", resultQuery.Status.EffectiveEndTime, query.Spec.EndTime) + } +} + +// TestQueryStorage_Create_ScopeExtraction tests that scope is properly extracted from user context +func TestQueryStorage_Create_ScopeExtraction(t *testing.T) { + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + var capturedScope storage.ScopeContext + + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + capturedScope = scope + return &storage.QueryResult{Events: []auditv1.Event{}}, nil + }, + } + qs := &QueryStorage{storage: mockStorage} + + tests := []struct { + name string + user user.Info + wantType string + wantName string + }{ + { + name: "organization scope", + user: &user.DefaultInfo{ + Name: "org-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Organization"}, + scope.ParentNameExtraKey: {"acme-corp"}, + }, + }, + wantType: "organization", + wantName: "acme-corp", + }, + { + name: "project scope", + user: &user.DefaultInfo{ + Name: "project-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Project"}, + scope.ParentNameExtraKey: {"backend-api"}, + }, + }, + wantType: "project", + wantName: "backend-api", + }, + { + name: "user scope", + user: &user.DefaultInfo{ + Name: "user-scoped", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"User"}, + scope.ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, + }, + }, + wantType: "user", + wantName: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "platform scope (no extra)", + user: &user.DefaultInfo{ + Name: "admin-user", + }, + wantType: "platform", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capturedScope = storage.ScopeContext{Type: "", Name: ""} // Reset + + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "scope-test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: yesterday.Format(time.RFC3339), + EndTime: now.Format(time.RFC3339), + Limit: 10, + }, + } + + ctx := request.WithUser(context.Background(), tt.user) + _, err := qs.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + if capturedScope.Type != tt.wantType { + t.Errorf("Scope.Type = %q, want %q", capturedScope.Type, tt.wantType) + } + if capturedScope.Name != tt.wantName { + t.Errorf("Scope.Name = %q, want %q", capturedScope.Name, tt.wantName) + } + }) + } +} + +// TestQueryStorage_Create_ValidationErrors tests validation errors through the public API +func TestQueryStorage_Create_ValidationErrors(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + } + qs := &QueryStorage{storage: mockStorage} + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + query *v1alpha1.AuditLogQuery + wantError string + }{ + { + name: "missing startTime", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + EndTime: "now", + }, + }, + wantError: "must specify a start time", + }, + { + name: "missing endTime", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-7d", + }, + }, + wantError: "must specify an end time", + }, + { + name: "invalid startTime format", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "2024/01/01", + EndTime: "now", + }, + }, + wantError: "invalid time format", + }, + { + name: "invalid endTime format", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-7d", + EndTime: "invalid", + }, + }, + wantError: "invalid time format", + }, + { + name: "endTime before startTime", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "2024-01-02T00:00:00Z", + EndTime: "2024-01-01T00:00:00Z", + }, + }, + wantError: "endTime must be after startTime", + }, + { + name: "same startTime and endTime", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-01-01T00:00:00Z", + }, + }, + wantError: "endTime must be after startTime", + }, + { + name: "time range exceeds maximum", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "2024-01-01T00:00:00Z", + EndTime: "2024-01-09T00:00:00Z", // 8 days > 7 day max + }, + }, + wantError: "time range of", + }, + { + name: "negative limit", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Limit: -10, + }, + }, + wantError: "limit must be non-negative", + }, + { + name: "limit exceeds maximum", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Limit: 2000, // > 1000 max + }, + }, + wantError: "limit of 2000 exceeds maximum of 1000", + }, + { + name: "invalid cursor format", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Limit: 100, + Continue: "invalid-cursor!@#$", + }, + }, + wantError: "cannot decode pagination cursor", + }, + { + name: "invalid CEL filter syntax", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: "verb === 'delete'", // invalid syntax (triple equals) + }, + }, + wantError: "Invalid filter", // Friendly error message + }, + { + name: "invalid CEL field access", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: "nonexistentField == 'value'", + }, + }, + wantError: "undeclared reference", + }, + { + name: "CEL field without dot notation", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: "user == 'admin'", // should be user.username + }, + }, + wantError: "found no matching overload", + }, + { + name: "CEL filter with wrong return type", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: "verb", // returns string, not boolean + }, + }, + wantError: "filter expression must return a boolean", + }, + { + name: "invalid field name on objectRef (plural instead of singular)", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: `objectRef.resources == "domains"`, // should be objectRef.resource (singular) + }, + }, + wantError: "field 'objectRef.resources' is not available for filtering", + }, + { + name: "invalid field name on user", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: `user.name == "admin"`, // should be user.username + }, + }, + wantError: "field 'user.name' is not available for filtering", + }, + { + name: "invalid field name on responseStatus", + query: &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: "now", + Filter: `responseStatus.status == 200`, // should be responseStatus.code + }, + }, + wantError: "field 'responseStatus.status' is not available for filtering", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := qs.Create(ctx, tt.query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + // Should return Invalid status error + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != 422 { + t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) + } + + if string(statusErr.ErrStatus.Reason) != "Invalid" { + t.Errorf("Reason = %q, want %q", statusErr.ErrStatus.Reason, "Invalid") + } + + errStr := err.Error() + if !strings.Contains(errStr, tt.wantError) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) + } + }) + } +} + +// TestQueryStorage_Create_StorageErrors tests error handling from the storage layer. +// CEL validation errors are now caught at the API layer, so storage errors should +// only be runtime database errors. +func TestQueryStorage_Create_StorageErrors(t *testing.T) { + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + tests := []struct { + name string + storageError error + wantStatus int32 + wantContains string + }{ + { + name: "database connection error", + storageError: fmt.Errorf("connection failed"), + wantStatus: 503, + wantContains: "Failed to execute query", + }, + { + name: "query timeout", + storageError: fmt.Errorf("context deadline exceeded"), + wantStatus: 503, + wantContains: "Failed to execute query", + }, + { + name: "clickhouse error", + storageError: fmt.Errorf("clickhouse: table not found"), + wantStatus: 503, + wantContains: "Failed to execute query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + return nil, tt.storageError + }, + } + qs := &QueryStorage{storage: mockStorage} + + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: yesterday.Format(time.RFC3339), + EndTime: now.Format(time.RFC3339), + Filter: "verb == 'delete'", + Limit: 100, + }, + } + + _, err := qs.Create(ctx, query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != tt.wantStatus { + t.Errorf("Status code = %d, want %d", statusErr.ErrStatus.Code, tt.wantStatus) + } + + errStr := err.Error() + if !strings.Contains(errStr, tt.wantContains) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantContains) + } + }) + } +} + +// TestQueryStorage_Create_NoUserContext tests that missing user context returns error +func TestQueryStorage_Create_NoUserContext(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + } + qs := &QueryStorage{storage: mockStorage} + + now := time.Now() + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: "now-1h", + EndTime: now.Format(time.RFC3339), + }, + } + + // Create without user context + _, err := qs.Create(context.Background(), query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != 500 { + t.Errorf("Status code = %d, want 500", statusErr.ErrStatus.Code) + } +} + +// TestQueryStorage_Create_WrongObjectType tests that non-AuditLogQuery objects are rejected +func TestQueryStorage_Create_WrongObjectType(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + } + qs := &QueryStorage{storage: mockStorage} + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + // Pass wrong object type + wrongObj := &v1alpha1.ActivityPolicy{} + _, err := qs.Create(ctx, wrongObj, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + if !strings.Contains(err.Error(), "not an AuditLogQuery") { + t.Errorf("Error message %q should contain 'not an AuditLogQuery'", err.Error()) + } +} + +// TestQueryStorage_Create_CursorValidation tests cursor validation at the API layer +func TestQueryStorage_Create_CursorValidation(t *testing.T) { + now := time.Now() + yesterday := now.Add(-24 * time.Hour) + + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + } + qs := &QueryStorage{storage: mockStorage} + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + cursor string + wantError string + }{ + { + name: "invalid base64 cursor", + cursor: "not-valid-base64!@#$", + wantError: "cannot decode pagination cursor", + }, + { + name: "invalid JSON cursor", + cursor: "aW52YWxpZGpzb24=", // base64("invalidjson") + wantError: "cursor format is invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: yesterday.Format(time.RFC3339), + EndTime: now.Format(time.RFC3339), + Limit: 100, + Continue: tt.cursor, + }, + } + + _, err := qs.Create(ctx, query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + // Should return Invalid status error (422) + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != 422 { + t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) + } + + if string(statusErr.ErrStatus.Reason) != "Invalid" { + t.Errorf("Reason = %q, want %q", statusErr.ErrStatus.Reason, "Invalid") + } + + errStr := err.Error() + if !strings.Contains(errStr, tt.wantError) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) + } + + // Verify the error is on the continue field + if !strings.Contains(errStr, "continue") { + t.Errorf("Error should reference 'continue' field, got: %s", errStr) + } + }) + } +} + +// TestQueryStorage_Create_RelativeTimeFormats tests various relative time formats +func TestQueryStorage_Create_RelativeTimeFormats(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 7 * 24 * time.Hour, + maxPageSize: 1000, + queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + return &storage.QueryResult{Events: []auditv1.Event{}}, nil + }, + } + qs := &QueryStorage{storage: mockStorage} + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + startTime string + endTime string + wantValid bool + }{ + { + name: "relative times with days", + startTime: "now-6d", + endTime: "now", + wantValid: true, + }, + { + name: "relative times with hours", + startTime: "now-24h", + endTime: "now", + wantValid: true, + }, + { + name: "relative times with minutes", + startTime: "now-30m", + endTime: "now", + wantValid: true, + }, + { + name: "mixed RFC3339 and relative", + startTime: "now-48h", + endTime: "now", + wantValid: true, + }, + { + name: "both RFC3339", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-02T00:00:00Z", + wantValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: tt.startTime, + EndTime: tt.endTime, + Limit: 100, + }, + } + + _, err := qs.Create(ctx, query, nil, nil) + + if tt.wantValid && err != nil { + t.Errorf("Create() error = %v, want nil", err) + } + if !tt.wantValid && err == nil { + t.Error("Create() error = nil, want error") + } + }) + } +} + +// TestQueryStorage_Create_EffectiveTimestamps tests that effective timestamps are correctly populated +func TestQueryStorage_Create_EffectiveTimestamps(t *testing.T) { + mockStorage := &mockStorageInterface{ + maxQueryWindow: 30 * 24 * time.Hour, + maxPageSize: 1000, + queryFunc: func(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope storage.ScopeContext) (*storage.QueryResult, error) { + return &storage.QueryResult{Events: []auditv1.Event{}}, nil + }, + } + qs := &QueryStorage{storage: mockStorage} + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + startTime string + endTime string + checkFunc func(t *testing.T, query *v1alpha1.AuditLogQuery) + }{ + { + name: "relative times - both relative", + startTime: "now-7d", + endTime: "now", + checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { + // Should have RFC3339 formatted timestamps + if query.Status.EffectiveStartTime == "" { + t.Error("EffectiveStartTime is empty") + } + if query.Status.EffectiveEndTime == "" { + t.Error("EffectiveEndTime is empty") + } + + // Parse to verify they're valid RFC3339 + startTime, err := time.Parse(time.RFC3339, query.Status.EffectiveStartTime) + if err != nil { + t.Errorf("EffectiveStartTime is not valid RFC3339: %v", err) + } + endTime, err := time.Parse(time.RFC3339, query.Status.EffectiveEndTime) + if err != nil { + t.Errorf("EffectiveEndTime is not valid RFC3339: %v", err) + } + + // Verify the time range is approximately 7 days. + // Tolerance accounts for DST transitions — AddDate preserves + // wall-clock time, so elapsed duration can differ by up to 1 hour + // when a DST boundary falls within the 7-day window. + duration := endTime.Sub(startTime) + expectedDuration := 7 * 24 * time.Hour + tolerance := time.Hour + time.Second + if duration < expectedDuration-tolerance || duration > expectedDuration+tolerance { + t.Errorf("Time range = %v, want ~%v (±%v for DST)", duration, expectedDuration, tolerance) + } + + // Verify endTime is very close to now (within 1 second) + now := time.Now() + timeDiff := now.Sub(endTime) + if timeDiff < 0 { + timeDiff = -timeDiff + } + if timeDiff > time.Second { + t.Errorf("EffectiveEndTime is %v away from now, expected < 1s", timeDiff) + } + }, + }, + { + name: "absolute times - both absolute", + startTime: "2024-01-01T00:00:00Z", + endTime: "2024-01-02T00:00:00Z", + checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { + // Should match exactly + if query.Status.EffectiveStartTime != "2024-01-01T00:00:00Z" { + t.Errorf("EffectiveStartTime = %q, want %q", query.Status.EffectiveStartTime, "2024-01-01T00:00:00Z") + } + if query.Status.EffectiveEndTime != "2024-01-02T00:00:00Z" { + t.Errorf("EffectiveEndTime = %q, want %q", query.Status.EffectiveEndTime, "2024-01-02T00:00:00Z") + } + }, + }, + { + name: "mixed times - relative start, relative end", + startTime: "now-48h", + endTime: "now-24h", + checkFunc: func(t *testing.T, query *v1alpha1.AuditLogQuery) { + // EffectiveStartTime should be RFC3339 formatted (from relative time) + if query.Status.EffectiveStartTime == "" { + t.Error("EffectiveStartTime is empty") + } + startTime, err := time.Parse(time.RFC3339, query.Status.EffectiveStartTime) + if err != nil { + t.Errorf("EffectiveStartTime is not valid RFC3339: %v", err) + } + + // Should be approximately 48 hours before now + now := time.Now() + duration := now.Sub(startTime) + expectedDuration := 48 * time.Hour + if duration < expectedDuration-time.Second || duration > expectedDuration+time.Second { + t.Errorf("Time from start to now = %v, want ~%v", duration, expectedDuration) + } + + // EffectiveEndTime should be RFC3339 formatted (from relative time) + if query.Status.EffectiveEndTime == "" { + t.Error("EffectiveEndTime is empty") + } + endTime, err := time.Parse(time.RFC3339, query.Status.EffectiveEndTime) + if err != nil { + t.Errorf("EffectiveEndTime is not valid RFC3339: %v", err) + } + + // Should be approximately 24 hours before now + duration = now.Sub(endTime) + expectedDuration = 24 * time.Hour + if duration < expectedDuration-time.Second || duration > expectedDuration+time.Second { + t.Errorf("Time from end to now = %v, want ~%v", duration, expectedDuration) + } + + // Verify startTime is before endTime + if !startTime.Before(endTime) { + t.Errorf("startTime %v should be before endTime %v", startTime, endTime) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: tt.startTime, + EndTime: tt.endTime, + }, + } + + result, err := qs.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + resultQuery := result.(*v1alpha1.AuditLogQuery) + tt.checkFunc(t, resultQuery) + }) + } +} diff --git a/internal/registry/activity/events/scope.go b/internal/registry/activity/events/scope.go index 3c88ee5e..b52551d4 100644 --- a/internal/registry/activity/events/scope.go +++ b/internal/registry/activity/events/scope.go @@ -1,48 +1,48 @@ -package events - -import ( - "k8s.io/apiserver/pkg/authentication/user" -) - -const ( - // Extra field keys set by Milo's authentication system to indicate resource hierarchy. - ParentAPIGroupExtraKey = "iam.miloapis.com/parent-api-group" - ParentKindExtraKey = "iam.miloapis.com/parent-type" - ParentNameExtraKey = "iam.miloapis.com/parent-name" -) - -// ScopeInfo represents the hierarchical scope for events queries. -// Used to restrict query results to the appropriate organizational boundary. -type ScopeInfo struct { - Type string // "platform", "organization", "project", "user" - Name string // scope identifier (org name, project name, user UID, etc.) -} - -// ExtractScopeFromUser determines the events query scope from user authentication metadata. -// Defaults to platform-wide scope when no parent resource is specified. -// -// For user scope, the Name field contains the user's UID (not username), which enables -// querying all events within that user's context across all organizations and projects. -func ExtractScopeFromUser(u user.Info) ScopeInfo { - if u.GetExtra() == nil { - return ScopeInfo{Type: "platform", Name: ""} - } - - parentKind := u.GetExtra()[ParentKindExtraKey] - parentName := u.GetExtra()[ParentNameExtraKey] - - if len(parentKind) == 0 || len(parentName) == 0 { - return ScopeInfo{Type: "platform", Name: ""} - } - - switch parentKind[0] { - case "Organization": - return ScopeInfo{Type: "organization", Name: parentName[0]} - case "Project": - return ScopeInfo{Type: "project", Name: parentName[0]} - case "User": - return ScopeInfo{Type: "user", Name: parentName[0]} - default: - return ScopeInfo{Type: "platform", Name: ""} - } -} +package events + +import ( + "k8s.io/apiserver/pkg/authentication/user" +) + +const ( + // Extra field keys set by Milo's authentication system to indicate resource hierarchy. + ParentAPIGroupExtraKey = "iam.miloapis.com/parent-api-group" + ParentKindExtraKey = "iam.miloapis.com/parent-type" + ParentNameExtraKey = "iam.miloapis.com/parent-name" +) + +// ScopeInfo represents the hierarchical scope for events queries. +// Used to restrict query results to the appropriate organizational boundary. +type ScopeInfo struct { + Type string // "platform", "organization", "project", "user" + Name string // scope identifier (org name, project name, user UID, etc.) +} + +// ExtractScopeFromUser determines the events query scope from user authentication metadata. +// Defaults to platform-wide scope when no parent resource is specified. +// +// For user scope, the Name field contains the user's UID (not username), which enables +// querying all events within that user's context across all organizations and projects. +func ExtractScopeFromUser(u user.Info) ScopeInfo { + if u.GetExtra() == nil { + return ScopeInfo{Type: "platform", Name: ""} + } + + parentKind := u.GetExtra()[ParentKindExtraKey] + parentName := u.GetExtra()[ParentNameExtraKey] + + if len(parentKind) == 0 || len(parentName) == 0 { + return ScopeInfo{Type: "platform", Name: ""} + } + + switch parentKind[0] { + case "Organization": + return ScopeInfo{Type: "organization", Name: parentName[0]} + case "Project": + return ScopeInfo{Type: "project", Name: parentName[0]} + case "User": + return ScopeInfo{Type: "user", Name: parentName[0]} + default: + return ScopeInfo{Type: "platform", Name: ""} + } +} diff --git a/internal/registry/activity/facet/storage_test.go b/internal/registry/activity/facet/storage_test.go index 73bc848d..bfa96e48 100644 --- a/internal/registry/activity/facet/storage_test.go +++ b/internal/registry/activity/facet/storage_test.go @@ -1,599 +1,599 @@ -package facet - -import ( - "context" - "fmt" - "strings" - "testing" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apiserver/pkg/authentication/user" - "k8s.io/apiserver/pkg/endpoints/request" - - "go.miloapis.com/activity/internal/registry/scope" - "go.miloapis.com/activity/internal/storage" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// mockFacetStorage is a test double for FacetStorageInterface -type mockFacetStorage struct { - queryFunc func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) -} - -func (m *mockFacetStorage) QueryFacets(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - if m.queryFunc != nil { - return m.queryFunc(ctx, spec, scope) - } - return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil -} - -// TestFacetQueryStorage_RESTInterface verifies the REST interface contracts -func TestFacetQueryStorage_RESTInterface(t *testing.T) { - s := NewFacetQueryStorage(&mockFacetStorage{}) - - t.Run("New returns empty ActivityFacetQuery", func(t *testing.T) { - obj := s.New() - query, ok := obj.(*v1alpha1.ActivityFacetQuery) - if !ok { - t.Errorf("New() returned %T, want *v1alpha1.ActivityFacetQuery", obj) - } - if query == nil { - t.Error("New() returned nil") - } - }) - - t.Run("NamespaceScoped returns false", func(t *testing.T) { - if s.NamespaceScoped() { - t.Error("NamespaceScoped() = true, want false") - } - }) - - t.Run("GetSingularName returns correct value", func(t *testing.T) { - want := "activityfacetquery" - if got := s.GetSingularName(); got != want { - t.Errorf("GetSingularName() = %q, want %q", got, want) - } - }) - - t.Run("Destroy doesn't panic", func(t *testing.T) { - defer func() { - if r := recover(); r != nil { - t.Errorf("Destroy() panicked: %v", r) - } - }() - s.Destroy() - }) -} - -// TestFacetQueryStorage_Create_Success tests successful facet query execution -func TestFacetQueryStorage_Create_Success(t *testing.T) { - mockResult := &storage.FacetQueryResult{ - Facets: []storage.FacetFieldResult{ - { - Field: "spec.actor.name", - Values: []storage.FacetValueResult{ - {Value: "alice", Count: 100}, - {Value: "bob", Count: 50}, - }, - }, - { - Field: "spec.resource.kind", - Values: []storage.FacetValueResult{ - {Value: "Deployment", Count: 75}, - {Value: "Service", Count: 25}, - }, - }, - }, - } - - mock := &mockFacetStorage{ - queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - return mockResult, nil - }, - } - s := NewFacetQueryStorage(mock) - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-facet-query"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - TimeRange: v1alpha1.FacetTimeRange{ - Start: "now-7d", - End: "now", - }, - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name", Limit: 10}, - {Field: "spec.resource.kind", Limit: 10}, - }, - }, - } - - testUser := &user.DefaultInfo{ - Name: "test-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Organization"}, - scope.ParentNameExtraKey: {"test-org"}, - }, - } - ctx := request.WithUser(context.Background(), testUser) - - result, err := s.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - resultQuery, ok := result.(*v1alpha1.ActivityFacetQuery) - if !ok { - t.Fatalf("Create() returned %T, want *v1alpha1.ActivityFacetQuery", result) - } - - // Verify facet results were populated - if len(resultQuery.Status.Facets) != 2 { - t.Errorf("Status.Facets has %d facets, want 2", len(resultQuery.Status.Facets)) - } - - // Verify first facet - if resultQuery.Status.Facets[0].Field != "spec.actor.name" { - t.Errorf("Facet[0].Field = %q, want %q", resultQuery.Status.Facets[0].Field, "spec.actor.name") - } - if len(resultQuery.Status.Facets[0].Values) != 2 { - t.Errorf("Facet[0].Values has %d values, want 2", len(resultQuery.Status.Facets[0].Values)) - } - if resultQuery.Status.Facets[0].Values[0].Value != "alice" { - t.Errorf("Facet[0].Values[0].Value = %q, want %q", resultQuery.Status.Facets[0].Values[0].Value, "alice") - } - if resultQuery.Status.Facets[0].Values[0].Count != 100 { - t.Errorf("Facet[0].Values[0].Count = %d, want 100", resultQuery.Status.Facets[0].Values[0].Count) - } -} - -// TestFacetQueryStorage_Create_ScopeExtraction tests that scope is properly extracted from user context -func TestFacetQueryStorage_Create_ScopeExtraction(t *testing.T) { - var capturedScope storage.ScopeContext - - mock := &mockFacetStorage{ - queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - capturedScope = scope - return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil - }, - } - s := NewFacetQueryStorage(mock) - - tests := []struct { - name string - user user.Info - wantType string - wantName string - }{ - { - name: "organization scope", - user: &user.DefaultInfo{ - Name: "org-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Organization"}, - scope.ParentNameExtraKey: {"acme-corp"}, - }, - }, - wantType: "organization", - wantName: "acme-corp", - }, - { - name: "project scope", - user: &user.DefaultInfo{ - Name: "project-user", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"Project"}, - scope.ParentNameExtraKey: {"backend-api"}, - }, - }, - wantType: "project", - wantName: "backend-api", - }, - { - name: "user scope", - user: &user.DefaultInfo{ - Name: "user-scoped", - Extra: map[string][]string{ - scope.ParentKindExtraKey: {"User"}, - scope.ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, - }, - }, - wantType: "user", - wantName: "550e8400-e29b-41d4-a716-446655440000", - }, - { - name: "platform scope (no extra)", - user: &user.DefaultInfo{ - Name: "admin-user", - }, - wantType: "platform", - wantName: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - capturedScope = storage.ScopeContext{} // Reset - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "scope-test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name"}, - }, - }, - } - - ctx := request.WithUser(context.Background(), tt.user) - _, err := s.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - if capturedScope.Type != tt.wantType { - t.Errorf("Scope.Type = %q, want %q", capturedScope.Type, tt.wantType) - } - if capturedScope.Name != tt.wantName { - t.Errorf("Scope.Name = %q, want %q", capturedScope.Name, tt.wantName) - } - }) - } -} - -// TestFacetQueryStorage_Create_ValidationErrors tests validation errors -func TestFacetQueryStorage_Create_ValidationErrors(t *testing.T) { - s := NewFacetQueryStorage(&mockFacetStorage{}) - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - query *v1alpha1.ActivityFacetQuery - wantError string - }{ - { - name: "empty facets list", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{}, - }, - }, - wantError: "Provide at least one facet", - }, - { - name: "too many facets", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name"}, - {Field: "spec.actor.type"}, - {Field: "spec.resource.apiGroup"}, - {Field: "spec.resource.kind"}, - {Field: "spec.resource.namespace"}, - {Field: "spec.changeSource"}, - {Field: "spec.actor.name"}, - {Field: "spec.actor.type"}, - {Field: "spec.resource.apiGroup"}, - {Field: "spec.resource.kind"}, - {Field: "spec.resource.namespace"}, // 11th facet - }, - }, - }, - wantError: "at most 10", - }, - { - name: "empty field name", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: ""}, - }, - }, - }, - wantError: "Specify which field", - }, - { - name: "invalid field name", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.invalid.field"}, - }, - }, - }, - wantError: "Supported values", - }, - { - name: "negative limit", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name", Limit: -5}, - }, - }, - }, - wantError: "Must be non-negative", - }, - { - name: "multiple errors - empty and invalid field", - query: &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: ""}, - {Field: "invalid.field"}, - }, - }, - }, - wantError: "fields are missing or invalid", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - _, err := s.Create(ctx, tt.query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - // Should return Invalid status error (422) - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - // Check if it's our custom StatusError - if customErr, ok := err.(interface{ Status() metav1.Status }); ok { - status := customErr.Status() - if status.Code != 422 { - t.Errorf("Status code = %d, want 422", status.Code) - } - if string(status.Reason) != "Invalid" { - t.Errorf("Reason = %q, want %q", status.Reason, "Invalid") - } - errStr := err.Error() - if !strings.Contains(errStr, tt.wantError) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) - } - return - } - t.Fatalf("Create() returned %T, want StatusError", err) - } - - if statusErr.ErrStatus.Code != 422 { - t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) - } - - errStr := err.Error() - if !strings.Contains(errStr, tt.wantError) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) - } - }) - } -} - -// TestFacetQueryStorage_Create_StorageErrors tests error handling from the storage layer -func TestFacetQueryStorage_Create_StorageErrors(t *testing.T) { - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - tests := []struct { - name string - storageError error - wantStatus int32 - wantContains string - }{ - { - name: "database connection error", - storageError: fmt.Errorf("connection failed"), - wantStatus: 503, - wantContains: "Failed to retrieve facets", - }, - { - name: "query timeout", - storageError: fmt.Errorf("context deadline exceeded"), - wantStatus: 503, - wantContains: "Failed to retrieve facets", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mock := &mockFacetStorage{ - queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - return nil, tt.storageError - }, - } - s := NewFacetQueryStorage(mock) - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name"}, - }, - }, - } - - _, err := s.Create(ctx, query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != tt.wantStatus { - t.Errorf("Status code = %d, want %d", statusErr.ErrStatus.Code, tt.wantStatus) - } - - errStr := err.Error() - if !strings.Contains(errStr, tt.wantContains) { - t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantContains) - } - }) - } -} - -// TestFacetQueryStorage_Create_NoUserContext tests that missing user context returns error -func TestFacetQueryStorage_Create_NoUserContext(t *testing.T) { - s := NewFacetQueryStorage(&mockFacetStorage{}) - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name"}, - }, - }, - } - - // Create without user context - _, err := s.Create(context.Background(), query, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - statusErr, ok := err.(*apierrors.StatusError) - if !ok { - t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) - } - - if statusErr.ErrStatus.Code != 500 { - t.Errorf("Status code = %d, want 500", statusErr.ErrStatus.Code) - } -} - -// TestFacetQueryStorage_Create_WrongObjectType tests that non-ActivityFacetQuery objects are rejected -func TestFacetQueryStorage_Create_WrongObjectType(t *testing.T) { - s := NewFacetQueryStorage(&mockFacetStorage{}) - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - // Pass wrong object type - wrongObj := &v1alpha1.ActivityPolicy{} - _, err := s.Create(ctx, wrongObj, nil, nil) - - if err == nil { - t.Fatal("Create() error = nil, want error") - } - - if !strings.Contains(err.Error(), "ActivityFacetQuery") { - t.Errorf("Error message %q should mention 'ActivityFacetQuery'", err.Error()) - } -} - -// TestFacetQueryStorage_Create_SpecPassthrough tests that spec fields are correctly passed to storage -func TestFacetQueryStorage_Create_SpecPassthrough(t *testing.T) { - var capturedSpec storage.FacetQuerySpec - - mock := &mockFacetStorage{ - queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - capturedSpec = spec - return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil - }, - } - s := NewFacetQueryStorage(mock) - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - TimeRange: v1alpha1.FacetTimeRange{ - Start: "now-7d", - End: "now", - }, - Filter: "spec.changeSource == 'human'", - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name", Limit: 25}, - {Field: "spec.resource.kind", Limit: 50}, - }, - }, - } - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - _, err := s.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - // Verify time range was passed - if capturedSpec.StartTime != "now-7d" { - t.Errorf("StartTime = %q, want %q", capturedSpec.StartTime, "now-7d") - } - if capturedSpec.EndTime != "now" { - t.Errorf("EndTime = %q, want %q", capturedSpec.EndTime, "now") - } - - // Verify filter was passed - if capturedSpec.Filter != "spec.changeSource == 'human'" { - t.Errorf("Filter = %q, want %q", capturedSpec.Filter, "spec.changeSource == 'human'") - } - - // Verify facets were passed - if len(capturedSpec.Facets) != 2 { - t.Fatalf("Facets has %d items, want 2", len(capturedSpec.Facets)) - } - if capturedSpec.Facets[0].Field != "spec.actor.name" { - t.Errorf("Facets[0].Field = %q, want %q", capturedSpec.Facets[0].Field, "spec.actor.name") - } - if capturedSpec.Facets[0].Limit != 25 { - t.Errorf("Facets[0].Limit = %d, want 25", capturedSpec.Facets[0].Limit) - } - if capturedSpec.Facets[1].Field != "spec.resource.kind" { - t.Errorf("Facets[1].Field = %q, want %q", capturedSpec.Facets[1].Field, "spec.resource.kind") - } - if capturedSpec.Facets[1].Limit != 50 { - t.Errorf("Facets[1].Limit = %d, want 50", capturedSpec.Facets[1].Limit) - } -} - -// TestFacetQueryStorage_Create_EmptyResults tests handling of empty results from storage -func TestFacetQueryStorage_Create_EmptyResults(t *testing.T) { - mock := &mockFacetStorage{ - queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { - return &storage.FacetQueryResult{ - Facets: []storage.FacetFieldResult{ - {Field: "spec.actor.name", Values: []storage.FacetValueResult{}}, - }, - }, nil - }, - } - s := NewFacetQueryStorage(mock) - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test"}, - Spec: v1alpha1.ActivityFacetQuerySpec{ - Facets: []v1alpha1.FacetSpec{ - {Field: "spec.actor.name"}, - }, - }, - } - - testUser := &user.DefaultInfo{Name: "test-user"} - ctx := request.WithUser(context.Background(), testUser) - - result, err := s.Create(ctx, query, nil, nil) - if err != nil { - t.Fatalf("Create() error = %v, want nil", err) - } - - resultQuery := result.(*v1alpha1.ActivityFacetQuery) - - if len(resultQuery.Status.Facets) != 1 { - t.Fatalf("Status.Facets has %d items, want 1", len(resultQuery.Status.Facets)) - } - if len(resultQuery.Status.Facets[0].Values) != 0 { - t.Errorf("Facets[0].Values has %d items, want 0", len(resultQuery.Status.Facets[0].Values)) - } -} +package facet + +import ( + "context" + "fmt" + "strings" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apiserver/pkg/authentication/user" + "k8s.io/apiserver/pkg/endpoints/request" + + "go.miloapis.com/activity/internal/registry/scope" + "go.miloapis.com/activity/internal/storage" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// mockFacetStorage is a test double for FacetStorageInterface +type mockFacetStorage struct { + queryFunc func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) +} + +func (m *mockFacetStorage) QueryFacets(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + if m.queryFunc != nil { + return m.queryFunc(ctx, spec, scope) + } + return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil +} + +// TestFacetQueryStorage_RESTInterface verifies the REST interface contracts +func TestFacetQueryStorage_RESTInterface(t *testing.T) { + s := NewFacetQueryStorage(&mockFacetStorage{}) + + t.Run("New returns empty ActivityFacetQuery", func(t *testing.T) { + obj := s.New() + query, ok := obj.(*v1alpha1.ActivityFacetQuery) + if !ok { + t.Errorf("New() returned %T, want *v1alpha1.ActivityFacetQuery", obj) + } + if query == nil { + t.Error("New() returned nil") + } + }) + + t.Run("NamespaceScoped returns false", func(t *testing.T) { + if s.NamespaceScoped() { + t.Error("NamespaceScoped() = true, want false") + } + }) + + t.Run("GetSingularName returns correct value", func(t *testing.T) { + want := "activityfacetquery" + if got := s.GetSingularName(); got != want { + t.Errorf("GetSingularName() = %q, want %q", got, want) + } + }) + + t.Run("Destroy doesn't panic", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Errorf("Destroy() panicked: %v", r) + } + }() + s.Destroy() + }) +} + +// TestFacetQueryStorage_Create_Success tests successful facet query execution +func TestFacetQueryStorage_Create_Success(t *testing.T) { + mockResult := &storage.FacetQueryResult{ + Facets: []storage.FacetFieldResult{ + { + Field: "spec.actor.name", + Values: []storage.FacetValueResult{ + {Value: "alice", Count: 100}, + {Value: "bob", Count: 50}, + }, + }, + { + Field: "spec.resource.kind", + Values: []storage.FacetValueResult{ + {Value: "Deployment", Count: 75}, + {Value: "Service", Count: 25}, + }, + }, + }, + } + + mock := &mockFacetStorage{ + queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + return mockResult, nil + }, + } + s := NewFacetQueryStorage(mock) + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-facet-query"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + TimeRange: v1alpha1.FacetTimeRange{ + Start: "now-7d", + End: "now", + }, + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name", Limit: 10}, + {Field: "spec.resource.kind", Limit: 10}, + }, + }, + } + + testUser := &user.DefaultInfo{ + Name: "test-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Organization"}, + scope.ParentNameExtraKey: {"test-org"}, + }, + } + ctx := request.WithUser(context.Background(), testUser) + + result, err := s.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + resultQuery, ok := result.(*v1alpha1.ActivityFacetQuery) + if !ok { + t.Fatalf("Create() returned %T, want *v1alpha1.ActivityFacetQuery", result) + } + + // Verify facet results were populated + if len(resultQuery.Status.Facets) != 2 { + t.Errorf("Status.Facets has %d facets, want 2", len(resultQuery.Status.Facets)) + } + + // Verify first facet + if resultQuery.Status.Facets[0].Field != "spec.actor.name" { + t.Errorf("Facet[0].Field = %q, want %q", resultQuery.Status.Facets[0].Field, "spec.actor.name") + } + if len(resultQuery.Status.Facets[0].Values) != 2 { + t.Errorf("Facet[0].Values has %d values, want 2", len(resultQuery.Status.Facets[0].Values)) + } + if resultQuery.Status.Facets[0].Values[0].Value != "alice" { + t.Errorf("Facet[0].Values[0].Value = %q, want %q", resultQuery.Status.Facets[0].Values[0].Value, "alice") + } + if resultQuery.Status.Facets[0].Values[0].Count != 100 { + t.Errorf("Facet[0].Values[0].Count = %d, want 100", resultQuery.Status.Facets[0].Values[0].Count) + } +} + +// TestFacetQueryStorage_Create_ScopeExtraction tests that scope is properly extracted from user context +func TestFacetQueryStorage_Create_ScopeExtraction(t *testing.T) { + var capturedScope storage.ScopeContext + + mock := &mockFacetStorage{ + queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + capturedScope = scope + return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil + }, + } + s := NewFacetQueryStorage(mock) + + tests := []struct { + name string + user user.Info + wantType string + wantName string + }{ + { + name: "organization scope", + user: &user.DefaultInfo{ + Name: "org-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Organization"}, + scope.ParentNameExtraKey: {"acme-corp"}, + }, + }, + wantType: "organization", + wantName: "acme-corp", + }, + { + name: "project scope", + user: &user.DefaultInfo{ + Name: "project-user", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"Project"}, + scope.ParentNameExtraKey: {"backend-api"}, + }, + }, + wantType: "project", + wantName: "backend-api", + }, + { + name: "user scope", + user: &user.DefaultInfo{ + Name: "user-scoped", + Extra: map[string][]string{ + scope.ParentKindExtraKey: {"User"}, + scope.ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, + }, + }, + wantType: "user", + wantName: "550e8400-e29b-41d4-a716-446655440000", + }, + { + name: "platform scope (no extra)", + user: &user.DefaultInfo{ + Name: "admin-user", + }, + wantType: "platform", + wantName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + capturedScope = storage.ScopeContext{} // Reset + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "scope-test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name"}, + }, + }, + } + + ctx := request.WithUser(context.Background(), tt.user) + _, err := s.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + if capturedScope.Type != tt.wantType { + t.Errorf("Scope.Type = %q, want %q", capturedScope.Type, tt.wantType) + } + if capturedScope.Name != tt.wantName { + t.Errorf("Scope.Name = %q, want %q", capturedScope.Name, tt.wantName) + } + }) + } +} + +// TestFacetQueryStorage_Create_ValidationErrors tests validation errors +func TestFacetQueryStorage_Create_ValidationErrors(t *testing.T) { + s := NewFacetQueryStorage(&mockFacetStorage{}) + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + query *v1alpha1.ActivityFacetQuery + wantError string + }{ + { + name: "empty facets list", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{}, + }, + }, + wantError: "Provide at least one facet", + }, + { + name: "too many facets", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name"}, + {Field: "spec.actor.type"}, + {Field: "spec.resource.apiGroup"}, + {Field: "spec.resource.kind"}, + {Field: "spec.resource.namespace"}, + {Field: "spec.changeSource"}, + {Field: "spec.actor.name"}, + {Field: "spec.actor.type"}, + {Field: "spec.resource.apiGroup"}, + {Field: "spec.resource.kind"}, + {Field: "spec.resource.namespace"}, // 11th facet + }, + }, + }, + wantError: "at most 10", + }, + { + name: "empty field name", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: ""}, + }, + }, + }, + wantError: "Specify which field", + }, + { + name: "invalid field name", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.invalid.field"}, + }, + }, + }, + wantError: "Supported values", + }, + { + name: "negative limit", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name", Limit: -5}, + }, + }, + }, + wantError: "Must be non-negative", + }, + { + name: "multiple errors - empty and invalid field", + query: &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: ""}, + {Field: "invalid.field"}, + }, + }, + }, + wantError: "fields are missing or invalid", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.Create(ctx, tt.query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + // Should return Invalid status error (422) + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + // Check if it's our custom StatusError + if customErr, ok := err.(interface{ Status() metav1.Status }); ok { + status := customErr.Status() + if status.Code != 422 { + t.Errorf("Status code = %d, want 422", status.Code) + } + if string(status.Reason) != "Invalid" { + t.Errorf("Reason = %q, want %q", status.Reason, "Invalid") + } + errStr := err.Error() + if !strings.Contains(errStr, tt.wantError) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) + } + return + } + t.Fatalf("Create() returned %T, want StatusError", err) + } + + if statusErr.ErrStatus.Code != 422 { + t.Errorf("Status code = %d, want 422", statusErr.ErrStatus.Code) + } + + errStr := err.Error() + if !strings.Contains(errStr, tt.wantError) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantError) + } + }) + } +} + +// TestFacetQueryStorage_Create_StorageErrors tests error handling from the storage layer +func TestFacetQueryStorage_Create_StorageErrors(t *testing.T) { + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + tests := []struct { + name string + storageError error + wantStatus int32 + wantContains string + }{ + { + name: "database connection error", + storageError: fmt.Errorf("connection failed"), + wantStatus: 503, + wantContains: "Failed to retrieve facets", + }, + { + name: "query timeout", + storageError: fmt.Errorf("context deadline exceeded"), + wantStatus: 503, + wantContains: "Failed to retrieve facets", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockFacetStorage{ + queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + return nil, tt.storageError + }, + } + s := NewFacetQueryStorage(mock) + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name"}, + }, + }, + } + + _, err := s.Create(ctx, query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != tt.wantStatus { + t.Errorf("Status code = %d, want %d", statusErr.ErrStatus.Code, tt.wantStatus) + } + + errStr := err.Error() + if !strings.Contains(errStr, tt.wantContains) { + t.Errorf("Error message %q doesn't contain %q", errStr, tt.wantContains) + } + }) + } +} + +// TestFacetQueryStorage_Create_NoUserContext tests that missing user context returns error +func TestFacetQueryStorage_Create_NoUserContext(t *testing.T) { + s := NewFacetQueryStorage(&mockFacetStorage{}) + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name"}, + }, + }, + } + + // Create without user context + _, err := s.Create(context.Background(), query, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + statusErr, ok := err.(*apierrors.StatusError) + if !ok { + t.Fatalf("Create() returned %T, want *apierrors.StatusError", err) + } + + if statusErr.ErrStatus.Code != 500 { + t.Errorf("Status code = %d, want 500", statusErr.ErrStatus.Code) + } +} + +// TestFacetQueryStorage_Create_WrongObjectType tests that non-ActivityFacetQuery objects are rejected +func TestFacetQueryStorage_Create_WrongObjectType(t *testing.T) { + s := NewFacetQueryStorage(&mockFacetStorage{}) + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + // Pass wrong object type + wrongObj := &v1alpha1.ActivityPolicy{} + _, err := s.Create(ctx, wrongObj, nil, nil) + + if err == nil { + t.Fatal("Create() error = nil, want error") + } + + if !strings.Contains(err.Error(), "ActivityFacetQuery") { + t.Errorf("Error message %q should mention 'ActivityFacetQuery'", err.Error()) + } +} + +// TestFacetQueryStorage_Create_SpecPassthrough tests that spec fields are correctly passed to storage +func TestFacetQueryStorage_Create_SpecPassthrough(t *testing.T) { + var capturedSpec storage.FacetQuerySpec + + mock := &mockFacetStorage{ + queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + capturedSpec = spec + return &storage.FacetQueryResult{Facets: []storage.FacetFieldResult{}}, nil + }, + } + s := NewFacetQueryStorage(mock) + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + TimeRange: v1alpha1.FacetTimeRange{ + Start: "now-7d", + End: "now", + }, + Filter: "spec.changeSource == 'human'", + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name", Limit: 25}, + {Field: "spec.resource.kind", Limit: 50}, + }, + }, + } + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + _, err := s.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + // Verify time range was passed + if capturedSpec.StartTime != "now-7d" { + t.Errorf("StartTime = %q, want %q", capturedSpec.StartTime, "now-7d") + } + if capturedSpec.EndTime != "now" { + t.Errorf("EndTime = %q, want %q", capturedSpec.EndTime, "now") + } + + // Verify filter was passed + if capturedSpec.Filter != "spec.changeSource == 'human'" { + t.Errorf("Filter = %q, want %q", capturedSpec.Filter, "spec.changeSource == 'human'") + } + + // Verify facets were passed + if len(capturedSpec.Facets) != 2 { + t.Fatalf("Facets has %d items, want 2", len(capturedSpec.Facets)) + } + if capturedSpec.Facets[0].Field != "spec.actor.name" { + t.Errorf("Facets[0].Field = %q, want %q", capturedSpec.Facets[0].Field, "spec.actor.name") + } + if capturedSpec.Facets[0].Limit != 25 { + t.Errorf("Facets[0].Limit = %d, want 25", capturedSpec.Facets[0].Limit) + } + if capturedSpec.Facets[1].Field != "spec.resource.kind" { + t.Errorf("Facets[1].Field = %q, want %q", capturedSpec.Facets[1].Field, "spec.resource.kind") + } + if capturedSpec.Facets[1].Limit != 50 { + t.Errorf("Facets[1].Limit = %d, want 50", capturedSpec.Facets[1].Limit) + } +} + +// TestFacetQueryStorage_Create_EmptyResults tests handling of empty results from storage +func TestFacetQueryStorage_Create_EmptyResults(t *testing.T) { + mock := &mockFacetStorage{ + queryFunc: func(ctx context.Context, spec storage.FacetQuerySpec, scope storage.ScopeContext) (*storage.FacetQueryResult, error) { + return &storage.FacetQueryResult{ + Facets: []storage.FacetFieldResult{ + {Field: "spec.actor.name", Values: []storage.FacetValueResult{}}, + }, + }, nil + }, + } + s := NewFacetQueryStorage(mock) + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test"}, + Spec: v1alpha1.ActivityFacetQuerySpec{ + Facets: []v1alpha1.FacetSpec{ + {Field: "spec.actor.name"}, + }, + }, + } + + testUser := &user.DefaultInfo{Name: "test-user"} + ctx := request.WithUser(context.Background(), testUser) + + result, err := s.Create(ctx, query, nil, nil) + if err != nil { + t.Fatalf("Create() error = %v, want nil", err) + } + + resultQuery := result.(*v1alpha1.ActivityFacetQuery) + + if len(resultQuery.Status.Facets) != 1 { + t.Fatalf("Status.Facets has %d items, want 1", len(resultQuery.Status.Facets)) + } + if len(resultQuery.Status.Facets[0].Values) != 0 { + t.Errorf("Facets[0].Values has %d items, want 0", len(resultQuery.Status.Facets[0].Values)) + } +} diff --git a/internal/registry/scope/scope.go b/internal/registry/scope/scope.go index 9cbb94fa..d111a85d 100644 --- a/internal/registry/scope/scope.go +++ b/internal/registry/scope/scope.go @@ -1,43 +1,43 @@ -package scope - -import ( - "k8s.io/apiserver/pkg/authentication/user" - - "go.miloapis.com/activity/internal/storage" -) - -const ( - // Extra field keys set by Milo's authentication system to indicate resource hierarchy. - ParentAPIGroupExtraKey = "iam.miloapis.com/parent-api-group" - ParentKindExtraKey = "iam.miloapis.com/parent-type" - ParentNameExtraKey = "iam.miloapis.com/parent-name" -) - -// ExtractScopeFromUser determines the query scope from user authentication metadata. -// Defaults to platform-wide scope when no parent resource is specified. -// -// For user scope, the Name field contains the user's UID (not username), which enables -// querying all activity performed by that user across all organizations and projects. -func ExtractScopeFromUser(u user.Info) storage.ScopeContext { - if u.GetExtra() == nil { - return storage.ScopeContext{Type: "platform", Name: ""} - } - - parentKind := u.GetExtra()[ParentKindExtraKey] - parentName := u.GetExtra()[ParentNameExtraKey] - - if len(parentKind) == 0 || len(parentName) == 0 { - return storage.ScopeContext{Type: "platform", Name: ""} - } - - switch parentKind[0] { - case "Organization": - return storage.ScopeContext{Type: "organization", Name: parentName[0]} - case "Project": - return storage.ScopeContext{Type: "project", Name: parentName[0]} - case "User": - return storage.ScopeContext{Type: "user", Name: parentName[0]} - default: - return storage.ScopeContext{Type: "platform", Name: ""} - } -} +package scope + +import ( + "k8s.io/apiserver/pkg/authentication/user" + + "go.miloapis.com/activity/internal/storage" +) + +const ( + // Extra field keys set by Milo's authentication system to indicate resource hierarchy. + ParentAPIGroupExtraKey = "iam.miloapis.com/parent-api-group" + ParentKindExtraKey = "iam.miloapis.com/parent-type" + ParentNameExtraKey = "iam.miloapis.com/parent-name" +) + +// ExtractScopeFromUser determines the query scope from user authentication metadata. +// Defaults to platform-wide scope when no parent resource is specified. +// +// For user scope, the Name field contains the user's UID (not username), which enables +// querying all activity performed by that user across all organizations and projects. +func ExtractScopeFromUser(u user.Info) storage.ScopeContext { + if u.GetExtra() == nil { + return storage.ScopeContext{Type: "platform", Name: ""} + } + + parentKind := u.GetExtra()[ParentKindExtraKey] + parentName := u.GetExtra()[ParentNameExtraKey] + + if len(parentKind) == 0 || len(parentName) == 0 { + return storage.ScopeContext{Type: "platform", Name: ""} + } + + switch parentKind[0] { + case "Organization": + return storage.ScopeContext{Type: "organization", Name: parentName[0]} + case "Project": + return storage.ScopeContext{Type: "project", Name: parentName[0]} + case "User": + return storage.ScopeContext{Type: "user", Name: parentName[0]} + default: + return storage.ScopeContext{Type: "platform", Name: ""} + } +} diff --git a/internal/registry/scope/scope_test.go b/internal/registry/scope/scope_test.go index 7a0f9330..092965de 100644 --- a/internal/registry/scope/scope_test.go +++ b/internal/registry/scope/scope_test.go @@ -1,107 +1,107 @@ -package scope - -import ( - "testing" - - "k8s.io/apiserver/pkg/authentication/user" - - "go.miloapis.com/activity/internal/storage" -) - -func TestExtractScopeFromUser(t *testing.T) { - tests := []struct { - name string - user user.Info - expected storage.ScopeContext - }{ - { - name: "organization scope", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {"Organization"}, - ParentNameExtraKey: {"acme-corp"}, - }, - }, - expected: storage.ScopeContext{Type: "organization", Name: "acme-corp"}, - }, - { - name: "project scope", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {"Project"}, - ParentNameExtraKey: {"backend-api"}, - }, - }, - expected: storage.ScopeContext{Type: "project", Name: "backend-api"}, - }, - { - name: "user scope", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {"User"}, - ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, - }, - }, - expected: storage.ScopeContext{Type: "user", Name: "550e8400-e29b-41d4-a716-446655440000"}, - }, - { - name: "no scope (platform)", - user: &user.DefaultInfo{}, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - { - name: "missing parent name", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {"Organization"}, - }, - }, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - { - name: "missing parent kind", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentNameExtraKey: {"acme-corp"}, - }, - }, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - { - name: "unknown parent kind", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {"UnknownType"}, - ParentNameExtraKey: {"some-name"}, - }, - }, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - { - name: "empty extra fields", - user: &user.DefaultInfo{ - Extra: map[string][]string{ - ParentKindExtraKey: {}, - ParentNameExtraKey: {}, - }, - }, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - { - name: "nil extra map", - user: &user.DefaultInfo{ - Name: "test-user", - }, - expected: storage.ScopeContext{Type: "platform", Name: ""}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := ExtractScopeFromUser(tt.user) - if result != tt.expected { - t.Errorf("got %+v, want %+v", result, tt.expected) - } - }) - } -} +package scope + +import ( + "testing" + + "k8s.io/apiserver/pkg/authentication/user" + + "go.miloapis.com/activity/internal/storage" +) + +func TestExtractScopeFromUser(t *testing.T) { + tests := []struct { + name string + user user.Info + expected storage.ScopeContext + }{ + { + name: "organization scope", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {"Organization"}, + ParentNameExtraKey: {"acme-corp"}, + }, + }, + expected: storage.ScopeContext{Type: "organization", Name: "acme-corp"}, + }, + { + name: "project scope", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {"Project"}, + ParentNameExtraKey: {"backend-api"}, + }, + }, + expected: storage.ScopeContext{Type: "project", Name: "backend-api"}, + }, + { + name: "user scope", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {"User"}, + ParentNameExtraKey: {"550e8400-e29b-41d4-a716-446655440000"}, + }, + }, + expected: storage.ScopeContext{Type: "user", Name: "550e8400-e29b-41d4-a716-446655440000"}, + }, + { + name: "no scope (platform)", + user: &user.DefaultInfo{}, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + { + name: "missing parent name", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {"Organization"}, + }, + }, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + { + name: "missing parent kind", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentNameExtraKey: {"acme-corp"}, + }, + }, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + { + name: "unknown parent kind", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {"UnknownType"}, + ParentNameExtraKey: {"some-name"}, + }, + }, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + { + name: "empty extra fields", + user: &user.DefaultInfo{ + Extra: map[string][]string{ + ParentKindExtraKey: {}, + ParentNameExtraKey: {}, + }, + }, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + { + name: "nil extra map", + user: &user.DefaultInfo{ + Name: "test-user", + }, + expected: storage.ScopeContext{Type: "platform", Name: ""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractScopeFromUser(tt.user) + if result != tt.expected { + t.Errorf("got %+v, want %+v", result, tt.expected) + } + }) + } +} diff --git a/internal/reindex/batch.go b/internal/reindex/batch.go index 8ab84fac..c6acd018 100644 --- a/internal/reindex/batch.go +++ b/internal/reindex/batch.go @@ -1,265 +1,265 @@ -package reindex - -import ( - "context" - "fmt" - "time" - - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/client" - - "go.miloapis.com/activity/internal/processor" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// fetchAuditLogBatch queries audit logs via the AuditLogQuery API. -// Returns the batch of audit events, the cursor for the next batch, and any error. -func fetchAuditLogBatch( - ctx context.Context, - c client.Client, - startTime, endTime time.Time, - cursor string, - batchSize int32, -) ([]*auditv1.Event, string, error) { - klog.V(4).InfoS("Fetching audit log batch via API", - "startTime", startTime, - "endTime", endTime, - "cursor", cursor, - "batchSize", batchSize, - ) - - // Create AuditLogQuery resource - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "reindex-audit-", - }, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: startTime.Format(time.RFC3339), - EndTime: endTime.Format(time.RFC3339), - Limit: batchSize, - Continue: cursor, - }, - } - - // Create the query - the API server returns results in status immediately - if err := c.Create(ctx, query); err != nil { - return nil, "", fmt.Errorf("failed to create AuditLogQuery: %w", err) - } - - klog.V(4).InfoS("AuditLogQuery executed", - "resultsCount", len(query.Status.Results), - "continue", query.Status.Continue, - ) - - // Convert results to pointers - batch := make([]*auditv1.Event, len(query.Status.Results)) - for i := range query.Status.Results { - batch[i] = &query.Status.Results[i] - } - - return batch, query.Status.Continue, nil -} - -// fetchEventBatch queries Kubernetes events via the EventQuery API. -// Returns the batch of events as maps, the cursor for the next batch, and any error. -func fetchEventBatch( - ctx context.Context, - c client.Client, - startTime, endTime time.Time, - cursor string, - batchSize int32, -) ([]map[string]interface{}, string, error) { - klog.V(4).InfoS("Fetching event batch via API", - "startTime", startTime, - "endTime", endTime, - "cursor", cursor, - "batchSize", batchSize, - ) - - // Create EventQuery resource - query := &v1alpha1.EventQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "reindex-event-", - }, - Spec: v1alpha1.EventQuerySpec{ - StartTime: startTime.Format(time.RFC3339), - EndTime: endTime.Format(time.RFC3339), - Limit: batchSize, - Continue: cursor, - }, - } - - // Create the query - the API server returns results in status immediately - if err := c.Create(ctx, query); err != nil { - return nil, "", fmt.Errorf("failed to create EventQuery: %w", err) - } - - klog.V(4).InfoS("EventQuery executed", - "resultsCount", len(query.Status.Results), - "continue", query.Status.Continue, - ) - - // Convert EventRecord results to map[string]interface{} for processor compatibility - batch := make([]map[string]interface{}, len(query.Status.Results)) - for i := range query.Status.Results { - // The processor expects the event data in map format - // EventRecord has an Event field that contains the actual event data - eventMap, err := eventRecordToMap(&query.Status.Results[i]) - if err != nil { - return nil, "", fmt.Errorf("failed to convert event record %d: %w", i, err) - } - batch[i] = eventMap - } - - return batch, query.Status.Continue, nil -} - -// evaluateBatch applies ActivityPolicy rules to a batch of events. -// The originType should be "audit" or "event" to indicate the source. -func evaluateBatch( - ctx context.Context, - batch interface{}, - policies []*v1alpha1.ActivityPolicy, - originType string, -) ([]*v1alpha1.Activity, error) { - var activities []*v1alpha1.Activity - - switch originType { - case "audit": - // Process audit logs - auditBatch, ok := batch.([]*auditv1.Event) - if !ok { - return nil, fmt.Errorf("invalid batch type for audit logs") - } - - for _, audit := range auditBatch { - for _, policy := range policies { - result, err := processor.EvaluateAuditRules(&policy.Spec, audit, nil) - if err != nil { - klog.ErrorS(err, "Failed to evaluate audit rules", - "policy", policy.Name, - "auditID", audit.AuditID, - ) - continue - } - - if result.Activity != nil { - // Add policy label for tracking - if result.Activity.Labels == nil { - result.Activity.Labels = make(map[string]string) - } - result.Activity.Labels["activity.miloapis.com/policy-name"] = policy.Name - - activities = append(activities, result.Activity) - break // Only first matching policy generates an activity - } - } - } - - case "event": - // Process Kubernetes events - eventBatch, ok := batch.([]map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid batch type for events") - } - - for _, eventMap := range eventBatch { - for _, policy := range policies { - result, err := processor.EvaluateEventRules(&policy.Spec, eventMap, nil) - if err != nil { - klog.ErrorS(err, "Failed to evaluate event rules", - "policy", policy.Name, - "eventUID", processor.GetNestedString(eventMap, "metadata", "uid"), - ) - continue - } - - if result.Activity != nil { - // Add policy label for tracking - if result.Activity.Labels == nil { - result.Activity.Labels = make(map[string]string) - } - result.Activity.Labels["activity.miloapis.com/policy-name"] = policy.Name - - activities = append(activities, result.Activity) - break // Only first matching policy generates an activity - } - } - } - - default: - return nil, fmt.Errorf("invalid origin type: %s", originType) - } - - klog.V(3).InfoS("Evaluated batch", - "originType", originType, - "inputEvents", batchSize(batch), - "activitiesGenerated", len(activities), - ) - - return activities, nil -} - -// eventRecordToMap converts an EventRecord to a map[string]interface{} for processor compatibility. -func eventRecordToMap(record *v1alpha1.EventRecord) (map[string]interface{}, error) { - // The processor expects the event data in a structured map format. - // We need to convert the eventsv1.Event to map[string]interface{}. - - // Create a map with the event fields that the processor expects - eventMap := map[string]interface{}{ - "metadata": map[string]interface{}{ - "name": record.Event.Name, - "namespace": record.Event.Namespace, - "uid": string(record.Event.UID), - }, - "regarding": map[string]interface{}{ - "apiVersion": record.Event.Regarding.APIVersion, - "kind": record.Event.Regarding.Kind, - "name": record.Event.Regarding.Name, - "namespace": record.Event.Regarding.Namespace, - "uid": string(record.Event.Regarding.UID), - }, - "reason": record.Event.Reason, - "note": record.Event.Note, - "type": record.Event.Type, - "eventTime": record.Event.EventTime.Time, - "reportingController": record.Event.ReportingController, - "reportingInstance": record.Event.ReportingInstance, - "action": record.Event.Action, - } - - // Add series if present - if record.Event.Series != nil { - eventMap["series"] = map[string]interface{}{ - "count": record.Event.Series.Count, - "lastObservedTime": record.Event.Series.LastObservedTime.Time, - } - } - - // Add related object if present - if record.Event.Related != nil { - eventMap["related"] = map[string]interface{}{ - "apiVersion": record.Event.Related.APIVersion, - "kind": record.Event.Related.Kind, - "name": record.Event.Related.Name, - "namespace": record.Event.Related.Namespace, - "uid": string(record.Event.Related.UID), - } - } - - return eventMap, nil -} - -// batchSize returns the size of a batch regardless of type. -func batchSize(batch interface{}) int { - switch v := batch.(type) { - case []*auditv1.Event: - return len(v) - case []map[string]interface{}: - return len(v) - default: - return 0 - } -} +package reindex + +import ( + "context" + "fmt" + "time" + + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.miloapis.com/activity/internal/processor" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// fetchAuditLogBatch queries audit logs via the AuditLogQuery API. +// Returns the batch of audit events, the cursor for the next batch, and any error. +func fetchAuditLogBatch( + ctx context.Context, + c client.Client, + startTime, endTime time.Time, + cursor string, + batchSize int32, +) ([]*auditv1.Event, string, error) { + klog.V(4).InfoS("Fetching audit log batch via API", + "startTime", startTime, + "endTime", endTime, + "cursor", cursor, + "batchSize", batchSize, + ) + + // Create AuditLogQuery resource + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "reindex-audit-", + }, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: startTime.Format(time.RFC3339), + EndTime: endTime.Format(time.RFC3339), + Limit: batchSize, + Continue: cursor, + }, + } + + // Create the query - the API server returns results in status immediately + if err := c.Create(ctx, query); err != nil { + return nil, "", fmt.Errorf("failed to create AuditLogQuery: %w", err) + } + + klog.V(4).InfoS("AuditLogQuery executed", + "resultsCount", len(query.Status.Results), + "continue", query.Status.Continue, + ) + + // Convert results to pointers + batch := make([]*auditv1.Event, len(query.Status.Results)) + for i := range query.Status.Results { + batch[i] = &query.Status.Results[i] + } + + return batch, query.Status.Continue, nil +} + +// fetchEventBatch queries Kubernetes events via the EventQuery API. +// Returns the batch of events as maps, the cursor for the next batch, and any error. +func fetchEventBatch( + ctx context.Context, + c client.Client, + startTime, endTime time.Time, + cursor string, + batchSize int32, +) ([]map[string]interface{}, string, error) { + klog.V(4).InfoS("Fetching event batch via API", + "startTime", startTime, + "endTime", endTime, + "cursor", cursor, + "batchSize", batchSize, + ) + + // Create EventQuery resource + query := &v1alpha1.EventQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "reindex-event-", + }, + Spec: v1alpha1.EventQuerySpec{ + StartTime: startTime.Format(time.RFC3339), + EndTime: endTime.Format(time.RFC3339), + Limit: batchSize, + Continue: cursor, + }, + } + + // Create the query - the API server returns results in status immediately + if err := c.Create(ctx, query); err != nil { + return nil, "", fmt.Errorf("failed to create EventQuery: %w", err) + } + + klog.V(4).InfoS("EventQuery executed", + "resultsCount", len(query.Status.Results), + "continue", query.Status.Continue, + ) + + // Convert EventRecord results to map[string]interface{} for processor compatibility + batch := make([]map[string]interface{}, len(query.Status.Results)) + for i := range query.Status.Results { + // The processor expects the event data in map format + // EventRecord has an Event field that contains the actual event data + eventMap, err := eventRecordToMap(&query.Status.Results[i]) + if err != nil { + return nil, "", fmt.Errorf("failed to convert event record %d: %w", i, err) + } + batch[i] = eventMap + } + + return batch, query.Status.Continue, nil +} + +// evaluateBatch applies ActivityPolicy rules to a batch of events. +// The originType should be "audit" or "event" to indicate the source. +func evaluateBatch( + ctx context.Context, + batch interface{}, + policies []*v1alpha1.ActivityPolicy, + originType string, +) ([]*v1alpha1.Activity, error) { + var activities []*v1alpha1.Activity + + switch originType { + case "audit": + // Process audit logs + auditBatch, ok := batch.([]*auditv1.Event) + if !ok { + return nil, fmt.Errorf("invalid batch type for audit logs") + } + + for _, audit := range auditBatch { + for _, policy := range policies { + result, err := processor.EvaluateAuditRules(&policy.Spec, audit, nil) + if err != nil { + klog.ErrorS(err, "Failed to evaluate audit rules", + "policy", policy.Name, + "auditID", audit.AuditID, + ) + continue + } + + if result.Activity != nil { + // Add policy label for tracking + if result.Activity.Labels == nil { + result.Activity.Labels = make(map[string]string) + } + result.Activity.Labels["activity.miloapis.com/policy-name"] = policy.Name + + activities = append(activities, result.Activity) + break // Only first matching policy generates an activity + } + } + } + + case "event": + // Process Kubernetes events + eventBatch, ok := batch.([]map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid batch type for events") + } + + for _, eventMap := range eventBatch { + for _, policy := range policies { + result, err := processor.EvaluateEventRules(&policy.Spec, eventMap, nil) + if err != nil { + klog.ErrorS(err, "Failed to evaluate event rules", + "policy", policy.Name, + "eventUID", processor.GetNestedString(eventMap, "metadata", "uid"), + ) + continue + } + + if result.Activity != nil { + // Add policy label for tracking + if result.Activity.Labels == nil { + result.Activity.Labels = make(map[string]string) + } + result.Activity.Labels["activity.miloapis.com/policy-name"] = policy.Name + + activities = append(activities, result.Activity) + break // Only first matching policy generates an activity + } + } + } + + default: + return nil, fmt.Errorf("invalid origin type: %s", originType) + } + + klog.V(3).InfoS("Evaluated batch", + "originType", originType, + "inputEvents", batchSize(batch), + "activitiesGenerated", len(activities), + ) + + return activities, nil +} + +// eventRecordToMap converts an EventRecord to a map[string]interface{} for processor compatibility. +func eventRecordToMap(record *v1alpha1.EventRecord) (map[string]interface{}, error) { + // The processor expects the event data in a structured map format. + // We need to convert the eventsv1.Event to map[string]interface{}. + + // Create a map with the event fields that the processor expects + eventMap := map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": record.Event.Name, + "namespace": record.Event.Namespace, + "uid": string(record.Event.UID), + }, + "regarding": map[string]interface{}{ + "apiVersion": record.Event.Regarding.APIVersion, + "kind": record.Event.Regarding.Kind, + "name": record.Event.Regarding.Name, + "namespace": record.Event.Regarding.Namespace, + "uid": string(record.Event.Regarding.UID), + }, + "reason": record.Event.Reason, + "note": record.Event.Note, + "type": record.Event.Type, + "eventTime": record.Event.EventTime.Time, + "reportingController": record.Event.ReportingController, + "reportingInstance": record.Event.ReportingInstance, + "action": record.Event.Action, + } + + // Add series if present + if record.Event.Series != nil { + eventMap["series"] = map[string]interface{}{ + "count": record.Event.Series.Count, + "lastObservedTime": record.Event.Series.LastObservedTime.Time, + } + } + + // Add related object if present + if record.Event.Related != nil { + eventMap["related"] = map[string]interface{}{ + "apiVersion": record.Event.Related.APIVersion, + "kind": record.Event.Related.Kind, + "name": record.Event.Related.Name, + "namespace": record.Event.Related.Namespace, + "uid": string(record.Event.Related.UID), + } + } + + return eventMap, nil +} + +// batchSize returns the size of a batch regardless of type. +func batchSize(batch interface{}) int { + switch v := batch.(type) { + case []*auditv1.Event: + return len(v) + case []map[string]interface{}: + return len(v) + default: + return 0 + } +} diff --git a/internal/reindex/ratelimiter.go b/internal/reindex/ratelimiter.go index f706832f..04067e8c 100644 --- a/internal/reindex/ratelimiter.go +++ b/internal/reindex/ratelimiter.go @@ -1,57 +1,57 @@ -package reindex - -import ( - "context" - "fmt" - "time" - - "golang.org/x/time/rate" -) - -// RateLimiter controls the rate of event processing using a token bucket algorithm. -type RateLimiter struct { - limiter *rate.Limiter -} - -// NewRateLimiter creates a new rate limiter that allows up to eventsPerSecond events -// with bursts up to 2x the rate. -func NewRateLimiter(eventsPerSecond int) *RateLimiter { - // Allow bursts up to 2x the rate - burst := eventsPerSecond * 2 - if burst < 1 { - burst = 1 - } - - return &RateLimiter{ - limiter: rate.NewLimiter(rate.Limit(eventsPerSecond), burst), - } -} - -// Wait blocks until n tokens are available or ctx is cancelled. -// Returns an error if the rate limit cannot be satisfied or if ctx is cancelled. -func (rl *RateLimiter) Wait(ctx context.Context, n int) error { - if n <= 0 { - return nil - } - - // Reserve n tokens - reservation := rl.limiter.ReserveN(time.Now(), n) - if !reservation.OK() { - return fmt.Errorf("rate limit exceeded: cannot reserve %d tokens", n) - } - - // Wait for the required delay - delay := reservation.Delay() - if delay > 0 { - select { - case <-time.After(delay): - return nil - case <-ctx.Done(): - // Cancel the reservation if context is cancelled - reservation.Cancel() - return ctx.Err() - } - } - - return nil -} +package reindex + +import ( + "context" + "fmt" + "time" + + "golang.org/x/time/rate" +) + +// RateLimiter controls the rate of event processing using a token bucket algorithm. +type RateLimiter struct { + limiter *rate.Limiter +} + +// NewRateLimiter creates a new rate limiter that allows up to eventsPerSecond events +// with bursts up to 2x the rate. +func NewRateLimiter(eventsPerSecond int) *RateLimiter { + // Allow bursts up to 2x the rate + burst := eventsPerSecond * 2 + if burst < 1 { + burst = 1 + } + + return &RateLimiter{ + limiter: rate.NewLimiter(rate.Limit(eventsPerSecond), burst), + } +} + +// Wait blocks until n tokens are available or ctx is cancelled. +// Returns an error if the rate limit cannot be satisfied or if ctx is cancelled. +func (rl *RateLimiter) Wait(ctx context.Context, n int) error { + if n <= 0 { + return nil + } + + // Reserve n tokens + reservation := rl.limiter.ReserveN(time.Now(), n) + if !reservation.OK() { + return fmt.Errorf("rate limit exceeded: cannot reserve %d tokens", n) + } + + // Wait for the required delay + delay := reservation.Delay() + if delay > 0 { + select { + case <-time.After(delay): + return nil + case <-ctx.Done(): + // Cancel the reservation if context is cancelled + reservation.Cancel() + return ctx.Err() + } + } + + return nil +} diff --git a/internal/reindex/reindexer.go b/internal/reindex/reindexer.go index 507b7268..cfbfc62c 100644 --- a/internal/reindex/reindexer.go +++ b/internal/reindex/reindexer.go @@ -1,462 +1,462 @@ -package reindex - -import ( - "context" - "fmt" - "time" - - "github.com/nats-io/nats.go" - "k8s.io/klog/v2" - "sigs.k8s.io/controller-runtime/pkg/client" - - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -// Reindexer orchestrates the re-indexing process by querying historical events -// via the Activity API server and publishing regenerated activities to NATS. -type Reindexer struct { - client client.Client - js nats.JetStreamContext - rateLimiter *RateLimiter - publisher *Publisher - - // OnProgress is called after each batch with updated progress information - OnProgress func(Progress) -} - -// Options configures a re-indexing operation. -type Options struct { - // StartTime is the beginning of the time range (inclusive) - StartTime time.Time - - // EndTime is the end of the time range (exclusive) - EndTime time.Time - - // BatchSize is the number of events to process per batch - BatchSize int32 - - // RateLimit is the maximum events per second to process - RateLimit int32 - - // DryRun previews changes without publishing to NATS - DryRun bool - - // PolicyNames limits processing to specific policies (nil = all policies) - PolicyNames []string - - // MatchLabels limits processing to policies with matching labels (nil = all policies) - MatchLabels map[string]string -} - -// Progress tracks the current state of a re-indexing operation. -type Progress struct { - // TotalEvents is the estimated total events to process - TotalEvents int64 - - // ProcessedEvents is the number of events processed so far - ProcessedEvents int64 - - // ActivitiesGenerated is the number of activities created - ActivitiesGenerated int64 - - // Errors is the count of non-fatal errors encountered - Errors int64 - - // CurrentBatch is the batch number currently being processed - CurrentBatch int32 - - // TotalBatches is the estimated total number of batches - TotalBatches int32 -} - -// NewReindexer creates a new Reindexer instance. -// The client is used to query AuditLogQuery and EventQuery resources via the API server, -// and to list ActivityPolicy resources. -func NewReindexer( - client client.Client, - js nats.JetStreamContext, -) *Reindexer { - return &Reindexer{ - client: client, - js: js, - publisher: NewPublisher(js), - } -} - -// Run executes the re-indexing operation with the provided options. -// It processes audit logs first, then Kubernetes events, applying the current -// ActivityPolicy rules to generate activities. -func (r *Reindexer) Run(ctx context.Context, opts Options) error { - startTime := time.Now() - defer func() { - duration := time.Since(startTime) - reindexDuration.WithLabelValues(formatTimeRange(opts.StartTime, opts.EndTime)).Observe(duration.Seconds()) - }() - - // Initialize rate limiter if configured - if opts.RateLimit > 0 { - r.rateLimiter = NewRateLimiter(int(opts.RateLimit)) - } - - // Fetch active policies to apply - policies, err := r.fetchActivePolicies(ctx, opts.PolicyNames, opts.MatchLabels) - if err != nil { - return fmt.Errorf("failed to fetch policies: %w", err) - } - - if len(policies) == 0 { - klog.InfoS("No policies to apply, exiting", "policyNames", opts.PolicyNames) - return nil - } - - klog.InfoS("Starting reindex job", - "startTime", opts.StartTime, - "endTime", opts.EndTime, - "policies", len(policies), - "batchSize", opts.BatchSize, - "rateLimit", opts.RateLimit, - "dryRun", opts.DryRun, - ) - - // Increment job counter - reindexJobsTotal.WithLabelValues( - formatTimeRange(opts.StartTime, opts.EndTime), - formatPolicyNames(opts.PolicyNames), - ).Inc() - - // Note: We don't estimate total events because the API doesn't have a count endpoint. - // Progress tracking will be best-effort based on batches processed. - progress := Progress{ - TotalEvents: 0, // Unknown when using API queries - TotalBatches: 0, // Unknown until complete - } - - // Process audit logs first - if err := r.processAuditLogs(ctx, opts, policies, &progress); err != nil { - return fmt.Errorf("failed to process audit logs: %w", err) - } - - // Process Kubernetes events second - if err := r.processEvents(ctx, opts, policies, &progress); err != nil { - return fmt.Errorf("failed to process events: %w", err) - } - - klog.InfoS("Reindex job completed", - "totalEventsProcessed", progress.ProcessedEvents, - "totalActivitiesGenerated", progress.ActivitiesGenerated, - "errors", progress.Errors, - "duration", time.Since(startTime), - ) - - return nil -} - -// fetchActivePolicies retrieves the policies to apply during re-indexing. -// If policyNames is provided, only those policies are fetched. -// If matchLabels is provided, only policies with matching labels are fetched. -// Otherwise, all active policies are fetched. -func (r *Reindexer) fetchActivePolicies(ctx context.Context, policyNames []string, matchLabels map[string]string) ([]*v1alpha1.ActivityPolicy, error) { - var policyList v1alpha1.ActivityPolicyList - if err := r.client.List(ctx, &policyList); err != nil { - return nil, fmt.Errorf("failed to list ActivityPolicy resources: %w", err) - } - - if len(policyList.Items) == 0 { - klog.V(2).InfoS("No ActivityPolicy resources found") - return []*v1alpha1.ActivityPolicy{}, nil - } - - // If specific policy names are requested, filter the list - if len(policyNames) > 0 { - nameSet := make(map[string]bool, len(policyNames)) - for _, name := range policyNames { - nameSet[name] = true - } - - filtered := make([]*v1alpha1.ActivityPolicy, 0, len(policyNames)) - for i := range policyList.Items { - if nameSet[policyList.Items[i].Name] { - filtered = append(filtered, &policyList.Items[i]) - } - } - - klog.V(2).InfoS("Filtered ActivityPolicy resources by name", - "requested", len(policyNames), - "found", len(filtered), - ) - return filtered, nil - } - - // If label selector is provided, filter by labels - if len(matchLabels) > 0 { - filtered := make([]*v1alpha1.ActivityPolicy, 0, len(policyList.Items)) - for i := range policyList.Items { - if matchesLabels(policyList.Items[i].Labels, matchLabels) { - filtered = append(filtered, &policyList.Items[i]) - } - } - - klog.V(2).InfoS("Filtered ActivityPolicy resources by labels", - "matchLabels", matchLabels, - "found", len(filtered), - ) - return filtered, nil - } - - // Convert all items to pointers - policies := make([]*v1alpha1.ActivityPolicy, len(policyList.Items)) - for i := range policyList.Items { - policies[i] = &policyList.Items[i] - } - - klog.V(2).InfoS("Fetched all ActivityPolicy resources", "count", len(policies)) - return policies, nil -} - -// matchesLabels returns true if the resource labels contain all the selector labels. -func matchesLabels(resourceLabels, selectorLabels map[string]string) bool { - if len(selectorLabels) == 0 { - return true - } - if len(resourceLabels) == 0 { - return false - } - for key, value := range selectorLabels { - if resourceLabels[key] != value { - return false - } - } - return true -} - - -// processAuditLogs processes all audit logs in the time range. -func (r *Reindexer) processAuditLogs(ctx context.Context, opts Options, policies []*v1alpha1.ActivityPolicy, progress *Progress) error { - klog.InfoS("Processing audit logs", "startTime", opts.StartTime, "endTime", opts.EndTime) - - cursor := "" - batchNum := int32(0) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - batchStart := time.Now() - batchNum++ - progress.CurrentBatch = batchNum - - // Fetch batch via AuditLogQuery API - batch, nextCursor, err := fetchAuditLogBatch(ctx, r.client, opts.StartTime, opts.EndTime, cursor, opts.BatchSize) - if err != nil { - reindexErrors.WithLabelValues("query").Inc() - return fmt.Errorf("failed to fetch audit log batch %d: %w", batchNum, err) - } - - if len(batch) == 0 { - klog.V(2).InfoS("No more audit logs to process") - break - } - - // Evaluate batch against policies - activities, err := evaluateBatch(ctx, batch, policies, "audit") - if err != nil { - reindexErrors.WithLabelValues("evaluate").Inc() - return fmt.Errorf("failed to evaluate audit batch %d: %w", batchNum, err) - } - - // Publish activities unless in dry-run mode - if !opts.DryRun && len(activities) > 0 { - if err := r.publisher.PublishActivities(ctx, activities); err != nil { - reindexErrors.WithLabelValues("publish").Inc() - return fmt.Errorf("failed to publish activities for batch %d: %w", batchNum, err) - } - - // Record published activities metric - for _, activity := range activities { - policyName := activity.Labels["activity.miloapis.com/policy-name"] - reindexActivitiesPublished.WithLabelValues(policyName).Inc() - } - } - - // Update progress - progress.ProcessedEvents += int64(len(batch)) - progress.ActivitiesGenerated += int64(len(activities)) - - // Record metrics - events processed is per batch, activities is per activity - reindexEventsProcessed.WithLabelValues("audit", "all", formatBool(opts.DryRun)).Add(float64(len(batch))) - for _, activity := range activities { - policyName := activity.Labels["activity.miloapis.com/policy-name"] - if policyName == "" { - policyName = "unknown" - } - reindexActivitiesGenerated.WithLabelValues(policyName, formatBool(opts.DryRun)).Inc() - } - - // Call progress callback if set - if r.OnProgress != nil { - r.OnProgress(*progress) - } - - klog.V(2).InfoS("Processed audit batch", - "batchNumber", batchNum, - "eventsProcessed", len(batch), - "activitiesGenerated", len(activities), - "totalProcessed", progress.ProcessedEvents, - "duration", time.Since(batchStart), - ) - - // Record batch duration - reindexBatchDuration.Observe(time.Since(batchStart).Seconds()) - - // Rate limiting - if r.rateLimiter != nil { - if err := r.rateLimiter.Wait(ctx, len(batch)); err != nil { - return err - } - } - - // Continue to next batch - if nextCursor == "" { - break - } - cursor = nextCursor - } - - klog.InfoS("Audit log processing complete", - "batches", batchNum, - "eventsProcessed", progress.ProcessedEvents, - "activitiesGenerated", progress.ActivitiesGenerated, - ) - - return nil -} - -// processEvents processes all Kubernetes events in the time range. -func (r *Reindexer) processEvents(ctx context.Context, opts Options, policies []*v1alpha1.ActivityPolicy, progress *Progress) error { - klog.InfoS("Processing Kubernetes events", "startTime", opts.StartTime, "endTime", opts.EndTime) - - cursor := "" - batchNum := int32(0) - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - batchStart := time.Now() - batchNum++ - progress.CurrentBatch = batchNum - - // Fetch batch via EventQuery API - batch, nextCursor, err := fetchEventBatch(ctx, r.client, opts.StartTime, opts.EndTime, cursor, opts.BatchSize) - if err != nil { - reindexErrors.WithLabelValues("query").Inc() - return fmt.Errorf("failed to fetch event batch %d: %w", batchNum, err) - } - - if len(batch) == 0 { - klog.V(2).InfoS("No more events to process") - break - } - - // Evaluate batch against policies - activities, err := evaluateBatch(ctx, batch, policies, "event") - if err != nil { - reindexErrors.WithLabelValues("evaluate").Inc() - return fmt.Errorf("failed to evaluate event batch %d: %w", batchNum, err) - } - - // Publish activities unless in dry-run mode - if !opts.DryRun && len(activities) > 0 { - if err := r.publisher.PublishActivities(ctx, activities); err != nil { - reindexErrors.WithLabelValues("publish").Inc() - return fmt.Errorf("failed to publish activities for batch %d: %w", batchNum, err) - } - - // Record published activities metric - for _, activity := range activities { - policyName := activity.Labels["activity.miloapis.com/policy-name"] - reindexActivitiesPublished.WithLabelValues(policyName).Inc() - } - } - - // Update progress - progress.ProcessedEvents += int64(len(batch)) - progress.ActivitiesGenerated += int64(len(activities)) - - // Record metrics - events processed is per batch, activities is per activity - reindexEventsProcessed.WithLabelValues("event", "all", formatBool(opts.DryRun)).Add(float64(len(batch))) - for _, activity := range activities { - policyName := activity.Labels["activity.miloapis.com/policy-name"] - if policyName == "" { - policyName = "unknown" - } - reindexActivitiesGenerated.WithLabelValues(policyName, formatBool(opts.DryRun)).Inc() - } - - // Call progress callback if set - if r.OnProgress != nil { - r.OnProgress(*progress) - } - - klog.V(2).InfoS("Processed event batch", - "batchNumber", batchNum, - "eventsProcessed", len(batch), - "activitiesGenerated", len(activities), - "totalProcessed", progress.ProcessedEvents, - "duration", time.Since(batchStart), - ) - - // Record batch duration - reindexBatchDuration.Observe(time.Since(batchStart).Seconds()) - - // Rate limiting - if r.rateLimiter != nil { - if err := r.rateLimiter.Wait(ctx, len(batch)); err != nil { - return err - } - } - - // Continue to next batch - if nextCursor == "" { - break - } - cursor = nextCursor - } - - klog.InfoS("Event processing complete", - "batches", batchNum, - "eventsProcessed", progress.ProcessedEvents, - "activitiesGenerated", progress.ActivitiesGenerated, - ) - - return nil -} - -// Helper functions - -func formatTimeRange(start, end time.Time) string { - return fmt.Sprintf("%s_%s", start.Format("20060102"), end.Format("20060102")) -} - -func formatPolicyNames(names []string) string { - if len(names) == 0 { - return "all" - } - if len(names) == 1 { - return names[0] - } - return fmt.Sprintf("%d_policies", len(names)) -} - -func formatBool(b bool) string { - if b { - return "true" - } - return "false" -} +package reindex + +import ( + "context" + "fmt" + "time" + + "github.com/nats-io/nats.go" + "k8s.io/klog/v2" + "sigs.k8s.io/controller-runtime/pkg/client" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +// Reindexer orchestrates the re-indexing process by querying historical events +// via the Activity API server and publishing regenerated activities to NATS. +type Reindexer struct { + client client.Client + js nats.JetStreamContext + rateLimiter *RateLimiter + publisher *Publisher + + // OnProgress is called after each batch with updated progress information + OnProgress func(Progress) +} + +// Options configures a re-indexing operation. +type Options struct { + // StartTime is the beginning of the time range (inclusive) + StartTime time.Time + + // EndTime is the end of the time range (exclusive) + EndTime time.Time + + // BatchSize is the number of events to process per batch + BatchSize int32 + + // RateLimit is the maximum events per second to process + RateLimit int32 + + // DryRun previews changes without publishing to NATS + DryRun bool + + // PolicyNames limits processing to specific policies (nil = all policies) + PolicyNames []string + + // MatchLabels limits processing to policies with matching labels (nil = all policies) + MatchLabels map[string]string +} + +// Progress tracks the current state of a re-indexing operation. +type Progress struct { + // TotalEvents is the estimated total events to process + TotalEvents int64 + + // ProcessedEvents is the number of events processed so far + ProcessedEvents int64 + + // ActivitiesGenerated is the number of activities created + ActivitiesGenerated int64 + + // Errors is the count of non-fatal errors encountered + Errors int64 + + // CurrentBatch is the batch number currently being processed + CurrentBatch int32 + + // TotalBatches is the estimated total number of batches + TotalBatches int32 +} + +// NewReindexer creates a new Reindexer instance. +// The client is used to query AuditLogQuery and EventQuery resources via the API server, +// and to list ActivityPolicy resources. +func NewReindexer( + client client.Client, + js nats.JetStreamContext, +) *Reindexer { + return &Reindexer{ + client: client, + js: js, + publisher: NewPublisher(js), + } +} + +// Run executes the re-indexing operation with the provided options. +// It processes audit logs first, then Kubernetes events, applying the current +// ActivityPolicy rules to generate activities. +func (r *Reindexer) Run(ctx context.Context, opts Options) error { + startTime := time.Now() + defer func() { + duration := time.Since(startTime) + reindexDuration.WithLabelValues(formatTimeRange(opts.StartTime, opts.EndTime)).Observe(duration.Seconds()) + }() + + // Initialize rate limiter if configured + if opts.RateLimit > 0 { + r.rateLimiter = NewRateLimiter(int(opts.RateLimit)) + } + + // Fetch active policies to apply + policies, err := r.fetchActivePolicies(ctx, opts.PolicyNames, opts.MatchLabels) + if err != nil { + return fmt.Errorf("failed to fetch policies: %w", err) + } + + if len(policies) == 0 { + klog.InfoS("No policies to apply, exiting", "policyNames", opts.PolicyNames) + return nil + } + + klog.InfoS("Starting reindex job", + "startTime", opts.StartTime, + "endTime", opts.EndTime, + "policies", len(policies), + "batchSize", opts.BatchSize, + "rateLimit", opts.RateLimit, + "dryRun", opts.DryRun, + ) + + // Increment job counter + reindexJobsTotal.WithLabelValues( + formatTimeRange(opts.StartTime, opts.EndTime), + formatPolicyNames(opts.PolicyNames), + ).Inc() + + // Note: We don't estimate total events because the API doesn't have a count endpoint. + // Progress tracking will be best-effort based on batches processed. + progress := Progress{ + TotalEvents: 0, // Unknown when using API queries + TotalBatches: 0, // Unknown until complete + } + + // Process audit logs first + if err := r.processAuditLogs(ctx, opts, policies, &progress); err != nil { + return fmt.Errorf("failed to process audit logs: %w", err) + } + + // Process Kubernetes events second + if err := r.processEvents(ctx, opts, policies, &progress); err != nil { + return fmt.Errorf("failed to process events: %w", err) + } + + klog.InfoS("Reindex job completed", + "totalEventsProcessed", progress.ProcessedEvents, + "totalActivitiesGenerated", progress.ActivitiesGenerated, + "errors", progress.Errors, + "duration", time.Since(startTime), + ) + + return nil +} + +// fetchActivePolicies retrieves the policies to apply during re-indexing. +// If policyNames is provided, only those policies are fetched. +// If matchLabels is provided, only policies with matching labels are fetched. +// Otherwise, all active policies are fetched. +func (r *Reindexer) fetchActivePolicies(ctx context.Context, policyNames []string, matchLabels map[string]string) ([]*v1alpha1.ActivityPolicy, error) { + var policyList v1alpha1.ActivityPolicyList + if err := r.client.List(ctx, &policyList); err != nil { + return nil, fmt.Errorf("failed to list ActivityPolicy resources: %w", err) + } + + if len(policyList.Items) == 0 { + klog.V(2).InfoS("No ActivityPolicy resources found") + return []*v1alpha1.ActivityPolicy{}, nil + } + + // If specific policy names are requested, filter the list + if len(policyNames) > 0 { + nameSet := make(map[string]bool, len(policyNames)) + for _, name := range policyNames { + nameSet[name] = true + } + + filtered := make([]*v1alpha1.ActivityPolicy, 0, len(policyNames)) + for i := range policyList.Items { + if nameSet[policyList.Items[i].Name] { + filtered = append(filtered, &policyList.Items[i]) + } + } + + klog.V(2).InfoS("Filtered ActivityPolicy resources by name", + "requested", len(policyNames), + "found", len(filtered), + ) + return filtered, nil + } + + // If label selector is provided, filter by labels + if len(matchLabels) > 0 { + filtered := make([]*v1alpha1.ActivityPolicy, 0, len(policyList.Items)) + for i := range policyList.Items { + if matchesLabels(policyList.Items[i].Labels, matchLabels) { + filtered = append(filtered, &policyList.Items[i]) + } + } + + klog.V(2).InfoS("Filtered ActivityPolicy resources by labels", + "matchLabels", matchLabels, + "found", len(filtered), + ) + return filtered, nil + } + + // Convert all items to pointers + policies := make([]*v1alpha1.ActivityPolicy, len(policyList.Items)) + for i := range policyList.Items { + policies[i] = &policyList.Items[i] + } + + klog.V(2).InfoS("Fetched all ActivityPolicy resources", "count", len(policies)) + return policies, nil +} + +// matchesLabels returns true if the resource labels contain all the selector labels. +func matchesLabels(resourceLabels, selectorLabels map[string]string) bool { + if len(selectorLabels) == 0 { + return true + } + if len(resourceLabels) == 0 { + return false + } + for key, value := range selectorLabels { + if resourceLabels[key] != value { + return false + } + } + return true +} + + +// processAuditLogs processes all audit logs in the time range. +func (r *Reindexer) processAuditLogs(ctx context.Context, opts Options, policies []*v1alpha1.ActivityPolicy, progress *Progress) error { + klog.InfoS("Processing audit logs", "startTime", opts.StartTime, "endTime", opts.EndTime) + + cursor := "" + batchNum := int32(0) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + batchStart := time.Now() + batchNum++ + progress.CurrentBatch = batchNum + + // Fetch batch via AuditLogQuery API + batch, nextCursor, err := fetchAuditLogBatch(ctx, r.client, opts.StartTime, opts.EndTime, cursor, opts.BatchSize) + if err != nil { + reindexErrors.WithLabelValues("query").Inc() + return fmt.Errorf("failed to fetch audit log batch %d: %w", batchNum, err) + } + + if len(batch) == 0 { + klog.V(2).InfoS("No more audit logs to process") + break + } + + // Evaluate batch against policies + activities, err := evaluateBatch(ctx, batch, policies, "audit") + if err != nil { + reindexErrors.WithLabelValues("evaluate").Inc() + return fmt.Errorf("failed to evaluate audit batch %d: %w", batchNum, err) + } + + // Publish activities unless in dry-run mode + if !opts.DryRun && len(activities) > 0 { + if err := r.publisher.PublishActivities(ctx, activities); err != nil { + reindexErrors.WithLabelValues("publish").Inc() + return fmt.Errorf("failed to publish activities for batch %d: %w", batchNum, err) + } + + // Record published activities metric + for _, activity := range activities { + policyName := activity.Labels["activity.miloapis.com/policy-name"] + reindexActivitiesPublished.WithLabelValues(policyName).Inc() + } + } + + // Update progress + progress.ProcessedEvents += int64(len(batch)) + progress.ActivitiesGenerated += int64(len(activities)) + + // Record metrics - events processed is per batch, activities is per activity + reindexEventsProcessed.WithLabelValues("audit", "all", formatBool(opts.DryRun)).Add(float64(len(batch))) + for _, activity := range activities { + policyName := activity.Labels["activity.miloapis.com/policy-name"] + if policyName == "" { + policyName = "unknown" + } + reindexActivitiesGenerated.WithLabelValues(policyName, formatBool(opts.DryRun)).Inc() + } + + // Call progress callback if set + if r.OnProgress != nil { + r.OnProgress(*progress) + } + + klog.V(2).InfoS("Processed audit batch", + "batchNumber", batchNum, + "eventsProcessed", len(batch), + "activitiesGenerated", len(activities), + "totalProcessed", progress.ProcessedEvents, + "duration", time.Since(batchStart), + ) + + // Record batch duration + reindexBatchDuration.Observe(time.Since(batchStart).Seconds()) + + // Rate limiting + if r.rateLimiter != nil { + if err := r.rateLimiter.Wait(ctx, len(batch)); err != nil { + return err + } + } + + // Continue to next batch + if nextCursor == "" { + break + } + cursor = nextCursor + } + + klog.InfoS("Audit log processing complete", + "batches", batchNum, + "eventsProcessed", progress.ProcessedEvents, + "activitiesGenerated", progress.ActivitiesGenerated, + ) + + return nil +} + +// processEvents processes all Kubernetes events in the time range. +func (r *Reindexer) processEvents(ctx context.Context, opts Options, policies []*v1alpha1.ActivityPolicy, progress *Progress) error { + klog.InfoS("Processing Kubernetes events", "startTime", opts.StartTime, "endTime", opts.EndTime) + + cursor := "" + batchNum := int32(0) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + batchStart := time.Now() + batchNum++ + progress.CurrentBatch = batchNum + + // Fetch batch via EventQuery API + batch, nextCursor, err := fetchEventBatch(ctx, r.client, opts.StartTime, opts.EndTime, cursor, opts.BatchSize) + if err != nil { + reindexErrors.WithLabelValues("query").Inc() + return fmt.Errorf("failed to fetch event batch %d: %w", batchNum, err) + } + + if len(batch) == 0 { + klog.V(2).InfoS("No more events to process") + break + } + + // Evaluate batch against policies + activities, err := evaluateBatch(ctx, batch, policies, "event") + if err != nil { + reindexErrors.WithLabelValues("evaluate").Inc() + return fmt.Errorf("failed to evaluate event batch %d: %w", batchNum, err) + } + + // Publish activities unless in dry-run mode + if !opts.DryRun && len(activities) > 0 { + if err := r.publisher.PublishActivities(ctx, activities); err != nil { + reindexErrors.WithLabelValues("publish").Inc() + return fmt.Errorf("failed to publish activities for batch %d: %w", batchNum, err) + } + + // Record published activities metric + for _, activity := range activities { + policyName := activity.Labels["activity.miloapis.com/policy-name"] + reindexActivitiesPublished.WithLabelValues(policyName).Inc() + } + } + + // Update progress + progress.ProcessedEvents += int64(len(batch)) + progress.ActivitiesGenerated += int64(len(activities)) + + // Record metrics - events processed is per batch, activities is per activity + reindexEventsProcessed.WithLabelValues("event", "all", formatBool(opts.DryRun)).Add(float64(len(batch))) + for _, activity := range activities { + policyName := activity.Labels["activity.miloapis.com/policy-name"] + if policyName == "" { + policyName = "unknown" + } + reindexActivitiesGenerated.WithLabelValues(policyName, formatBool(opts.DryRun)).Inc() + } + + // Call progress callback if set + if r.OnProgress != nil { + r.OnProgress(*progress) + } + + klog.V(2).InfoS("Processed event batch", + "batchNumber", batchNum, + "eventsProcessed", len(batch), + "activitiesGenerated", len(activities), + "totalProcessed", progress.ProcessedEvents, + "duration", time.Since(batchStart), + ) + + // Record batch duration + reindexBatchDuration.Observe(time.Since(batchStart).Seconds()) + + // Rate limiting + if r.rateLimiter != nil { + if err := r.rateLimiter.Wait(ctx, len(batch)); err != nil { + return err + } + } + + // Continue to next batch + if nextCursor == "" { + break + } + cursor = nextCursor + } + + klog.InfoS("Event processing complete", + "batches", batchNum, + "eventsProcessed", progress.ProcessedEvents, + "activitiesGenerated", progress.ActivitiesGenerated, + ) + + return nil +} + +// Helper functions + +func formatTimeRange(start, end time.Time) string { + return fmt.Sprintf("%s_%s", start.Format("20060102"), end.Format("20060102")) +} + +func formatPolicyNames(names []string) string { + if len(names) == 0 { + return "all" + } + if len(names) == 1 { + return names[0] + } + return fmt.Sprintf("%d_policies", len(names)) +} + +func formatBool(b bool) string { + if b { + return "true" + } + return "false" +} diff --git a/internal/storage/clickhouse.go b/internal/storage/clickhouse.go index cd90296c..0f7d8fb9 100644 --- a/internal/storage/clickhouse.go +++ b/internal/storage/clickhouse.go @@ -1,1298 +1,1298 @@ -package storage - -import ( - "context" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "encoding/json" - "fmt" - "os" - "strings" - "time" - - "github.com/ClickHouse/clickhouse-go/v2" - "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - "k8s.io/klog/v2" - - "go.miloapis.com/activity/internal/cel" - "go.miloapis.com/activity/internal/metrics" - "go.miloapis.com/activity/internal/timeutil" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -var tracer = otel.Tracer("activity-clickhouse-storage") - -const ( - // cursorTTL limits cursor lifetime to prevent replay attacks and stale queries. - cursorTTL = 1 * time.Hour -) - -// cursorData encodes pagination state and query validation information. -type cursorData struct { - Timestamp time.Time `json:"t"` // Event timestamp for pagination - AuditID string `json:"a"` // Audit ID for tie-breaking - QueryHash string `json:"h"` // Hash of query parameters - IssuedAt time.Time `json:"i"` // When cursor was created (for expiration) -} - -// hashQueryParams creates a hash to validate cursors are used with matching queries. -// Excludes continueAfter since it changes between pagination requests. -func hashQueryParams(spec v1alpha1.AuditLogQuerySpec) string { - h := sha256.New() - h.Write([]byte(spec.StartTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.EndTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.Filter)) - h.Write([]byte("|")) - h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) - - return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) -} - -// encodeCursor creates a base64-encoded pagination token containing position and validation data. -func encodeCursor(timestamp time.Time, auditID string, spec v1alpha1.AuditLogQuerySpec) string { - data := cursorData{ - Timestamp: timestamp, - AuditID: auditID, - QueryHash: hashQueryParams(spec), - IssuedAt: time.Now(), - } - - jsonData, _ := json.Marshal(data) - return base64.URLEncoding.EncodeToString(jsonData) -} - -// ValidateCursor checks if a cursor is valid for the given query spec without extracting data. -// This is called by the API layer during validation to provide early feedback. -// Returns an error if the cursor is malformed, expired, or doesn't match the query parameters. -func ValidateCursor(cursor string, spec v1alpha1.AuditLogQuerySpec) error { - _, _, err := decodeCursor(cursor, spec) - return err -} - -// decodeCursor validates and extracts pagination state from a cursor token. -// Returns an error if the cursor is malformed, expired, or doesn't match the current query. -func decodeCursor(cursor string, spec v1alpha1.AuditLogQuerySpec) (time.Time, string, error) { - decoded, err := base64.URLEncoding.DecodeString(cursor) - if err != nil { - return time.Time{}, "", fmt.Errorf("cannot decode pagination cursor: %w", err) - } - - var data cursorData - if err := json.Unmarshal(decoded, &data); err != nil { - return time.Time{}, "", fmt.Errorf("cursor format is invalid. Start a new query") - } - - currentHash := hashQueryParams(spec) - if data.QueryHash != currentHash { - return time.Time{}, "", fmt.Errorf("cannot use cursor because query parameters changed. Start a new query without the continueAfter parameter") - } - - if data.IssuedAt.IsZero() { - return time.Time{}, "", fmt.Errorf("cursor format is invalid. Start a new query") - } - - age := time.Since(data.IssuedAt) - if age > cursorTTL { - return time.Time{}, "", fmt.Errorf("cursor expired after %v. Cursors are valid for %v. Start a new query without the continueAfter parameter", - age.Round(time.Second), - cursorTTL, - ) - } - - return data.Timestamp, data.AuditID, nil -} - -// ClickHouseConfig configures the ClickHouse connection and query limits. -type ClickHouseConfig struct { - Address string - Database string - Username string - Password string - - // TLS configuration (optional - disabled by default) - TLSEnabled bool // Enable TLS for ClickHouse connection - TLSCertFile string // Path to client certificate file - TLSKeyFile string // Path to client key file - TLSCAFile string // Path to CA certificate file - - MaxQueryWindow time.Duration // Maximum allowed time range for queries - MaxPageSize int32 // Maximum results per page -} - -// ClickHouseStorage implements audit log storage using ClickHouse. -type ClickHouseStorage struct { - conn driver.Conn - config ClickHouseConfig -} - -// NewClickHouseStorage establishes a connection to ClickHouse and validates connectivity. -func NewClickHouseStorage(config ClickHouseConfig) (*ClickHouseStorage, error) { - options := &clickhouse.Options{ - Addr: []string{config.Address}, - Auth: clickhouse.Auth{ - Database: config.Database, - Username: config.Username, - Password: config.Password, - }, - Settings: clickhouse.Settings{ - "max_execution_time": 60, - }, - DialTimeout: 5 * time.Second, - Compression: &clickhouse.Compression{ - Method: clickhouse.CompressionLZ4, - }, - } - - // Configure TLS if enabled - if config.TLSEnabled { - tlsConfig, err := loadTLSConfig(config) - if err != nil { - return nil, fmt.Errorf("failed to load TLS configuration: %w", err) - } - options.TLS = tlsConfig - klog.V(2).Info("ClickHouse TLS enabled") - } - - conn, err := clickhouse.Open(options) - if err != nil { - return nil, fmt.Errorf("failed to connect to ClickHouse: %w", err) - } - - if err := conn.Ping(context.Background()); err != nil { - return nil, fmt.Errorf("failed to ping ClickHouse: %w", err) - } - - return &ClickHouseStorage{ - conn: conn, - config: config, - }, nil -} - -// loadTLSConfig loads TLS certificates and creates a tls.Config for ClickHouse connection. -func loadTLSConfig(config ClickHouseConfig) (*tls.Config, error) { - tlsConfig := &tls.Config{} - - // Load client certificate and key if provided - if config.TLSCertFile != "" && config.TLSKeyFile != "" { - cert, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile) - if err != nil { - return nil, fmt.Errorf("failed to load client certificate: %w", err) - } - tlsConfig.Certificates = []tls.Certificate{cert} - klog.V(2).Infof("Loaded client certificate from %s", config.TLSCertFile) - } - - // Load CA certificate if provided - if config.TLSCAFile != "" { - caCert, err := os.ReadFile(config.TLSCAFile) - if err != nil { - return nil, fmt.Errorf("failed to read CA certificate: %w", err) - } - - caCertPool := x509.NewCertPool() - if !caCertPool.AppendCertsFromPEM(caCert) { - return nil, fmt.Errorf("failed to parse CA certificate") - } - tlsConfig.RootCAs = caCertPool - klog.V(2).Infof("Loaded CA certificate from %s", config.TLSCAFile) - } - - return tlsConfig, nil -} - -func (s *ClickHouseStorage) Close() error { - if s.conn != nil { - return s.conn.Close() - } - return nil -} - -// Conn returns the underlying ClickHouse connection. -func (s *ClickHouseStorage) Conn() driver.Conn { - return s.conn -} - -// Config returns the ClickHouse configuration. -func (s *ClickHouseStorage) Config() ClickHouseConfig { - return s.config -} - -func (s *ClickHouseStorage) GetMaxQueryWindow() time.Duration { - return s.config.MaxQueryWindow -} - -func (s *ClickHouseStorage) GetMaxPageSize() int32 { - return s.config.MaxPageSize -} - -// QueryResult contains audit events and pagination state. -type QueryResult struct { - Events []auditv1.Event - Continue string -} - -// ScopeContext defines the hierarchical scope boundary for audit log queries. -type ScopeContext struct { - Type string // "platform", "organization", "project", "user" - Name string // scope identifier (org name, project name, etc.) -} - -// QueryAuditLogs retrieves audit logs matching the query specification and scope. -// The spec parameter must be pre-validated by the API layer. -func (s *ClickHouseStorage) QueryAuditLogs(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope ScopeContext) (*QueryResult, error) { - ctx, span := tracer.Start(ctx, "clickhouse.query", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", s.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.Int("query.limit", int(spec.Limit)), - attribute.String("query.filter", spec.Filter), - attribute.String("query.start_time", spec.StartTime), - attribute.String("query.end_time", spec.EndTime), - ), - ) - defer span.End() - - // Start timing the overall query operation - overallStartTime := time.Now() - - query, args, err := s.buildQuery(ctx, spec, scope) - if err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("build_query").Inc() - span.RecordError(err) - span.SetStatus(codes.Error, "failed to build query") - // Return the error directly - buildQuery returns user-friendly validation errors - return nil, err - } - - klog.V(3).InfoS("Built ClickHouse query", - "query", query, - "argsCount", len(args), - ) - - // Add SQL statement to span (truncated if too long) - truncatedQuery := query - if len(query) > 1000 { - truncatedQuery = query[:1000] + "..." - } - span.SetAttributes(attribute.String("db.statement", truncatedQuery)) - - // Add trace context as SQL comment for correlation - spanContext := span.SpanContext() - if spanContext.IsValid() { - traceparent := fmt.Sprintf("00-%s-%s-%02x", - spanContext.TraceID().String(), - spanContext.SpanID().String(), - spanContext.TraceFlags()) - query = fmt.Sprintf("/* traceparent: %s */ %s", traceparent, query) - } - - // Extract trace ID for logging - traceID := span.SpanContext().TraceID().String() - spanID := span.SpanContext().SpanID().String() - - klog.InfoS("Executing ClickHouse query", - "traceID", traceID, - "spanID", spanID, - "filter", spec.Filter, - "limit", spec.Limit, - "continue", spec.Continue, - "query", truncatedQuery, - ) - - // Time the actual ClickHouse query execution - queryStartTime := time.Now() - rows, err := s.conn.Query(ctx, query, args...) - queryDuration := time.Since(queryStartTime).Seconds() - - if err != nil { - metrics.ClickHouseQueryDuration.WithLabelValues("query").Observe(queryDuration) - metrics.ClickHouseQueryTotal.WithLabelValues("error").Inc() - - // Classify error type - errorType := "unknown" - if strings.Contains(err.Error(), "connection") { - errorType = "connection" - } else if strings.Contains(err.Error(), "timeout") { - errorType = "timeout" - } else if strings.Contains(err.Error(), "syntax") { - errorType = "syntax" - } else if strings.Contains(err.Error(), "memory") { - errorType = "memory" - } else if strings.Contains(err.Error(), "parameter") { - errorType = "parameter" - } - metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() - - // Record error in span - span.RecordError(err) - span.SetStatus(codes.Error, "query execution failed") - span.SetAttributes(attribute.String("error.type", errorType)) - - // Log detailed error with trace context and query details - klog.ErrorS(err, "ClickHouse query failed", - "traceID", traceID, - "spanID", spanID, - "errorType", errorType, - "filter", spec.Filter, - "limit", spec.Limit, - "continue", spec.Continue, - "duration", queryDuration, - "query", truncatedQuery, - ) - - return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") - } - defer rows.Close() - - // Record successful query execution time - metrics.ClickHouseQueryDuration.WithLabelValues("query").Observe(queryDuration) - span.SetAttributes(attribute.Float64("db.query_duration_seconds", queryDuration)) - - // Determine the limit - limit := spec.Limit - if limit <= 0 { - limit = 100 - } - if limit > s.config.MaxPageSize { - limit = s.config.MaxPageSize - } - - var events []auditv1.Event - var unmarshalErrors int - for rows.Next() { - var eventJSON string - if err := rows.Scan(&eventJSON); err != nil { - klog.ErrorS(err, "Failed to scan row", - "traceID", traceID, - "spanID", spanID, - ) - return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") - } - - var event auditv1.Event - if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { - unmarshalErrors++ - klog.ErrorS(err, "Failed to unmarshal audit event", - "traceID", traceID, - "spanID", spanID, - ) - continue - } - - events = append(events, event) - } - - if err := rows.Err(); err != nil { - metrics.ClickHouseQueryTotal.WithLabelValues("error").Inc() - metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() - - klog.ErrorS(err, "Error iterating ClickHouse rows", - "traceID", traceID, - "spanID", spanID, - "filter", spec.Filter, - "limit", spec.Limit, - ) - - return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") - } - - if unmarshalErrors > 0 { - klog.InfoS("Query completed with unmarshal errors", - "traceID", traceID, - "spanID", spanID, - "unmarshalErrors", unmarshalErrors, - "successfulEvents", len(events), - ) - } - - // Check if we have more results (we fetched limit+1) - var continueAfter string - if int32(len(events)) > limit { - events = events[:limit] - if len(events) > 0 { - lastEvent := events[len(events)-1] - continueAfter = encodeCursor(lastEvent.StageTimestamp.Time, string(lastEvent.AuditID), spec) - } - } - - // Record successful query metrics - metrics.ClickHouseQueryTotal.WithLabelValues("success").Inc() - metrics.AuditLogQueryResults.Observe(float64(len(events))) - - // Record end-to-end query duration (includes result processing) - totalDuration := time.Since(overallStartTime).Seconds() - metrics.ClickHouseQueryDuration.WithLabelValues("total").Observe(totalDuration) - - // Add result metrics to span - span.SetAttributes( - attribute.Int("db.rows_returned", len(events)), - attribute.Bool("query.has_more", continueAfter != ""), - attribute.Float64("db.total_duration_seconds", totalDuration), - ) - span.SetStatus(codes.Ok, "query successful") - - // Log successful query completion - klog.InfoS("ClickHouse query completed successfully", - "traceID", traceID, - "spanID", spanID, - "rowsReturned", len(events), - "hasMore", continueAfter != "", - "queryDuration", queryDuration, - "totalDuration", totalDuration, - "filter", spec.Filter, - "limit", spec.Limit, - ) - - return &QueryResult{ - Events: events, - Continue: continueAfter, - }, nil -} - -// hasUserFilter checks if the CEL filter contains user-based filtering -func hasUserFilter(filter string) bool { - if filter == "" { - return false - } - // Check for common user filter patterns in CEL expressions - // This is a heuristic - doesn't need to be perfect, just helpful for optimization - return strings.Contains(filter, "user.username") || - strings.Contains(filter, "user.groups") || - strings.Contains(filter, "user.uid") || - // Also match if someone uses the materialized column directly - (strings.Contains(filter, "user") && (strings.Contains(filter, "==") || strings.Contains(filter, "!="))) -} - -// hasAPIGroupFilter checks if the CEL filter expression contains API group fields. -// Added for future use — not currently wired into buildActivityQuery. -func hasAPIGroupFilter(filter string) bool { - if filter == "" { - return false - } - return strings.Contains(filter, "resource.apiGroup") || - strings.Contains(filter, "api_group") -} - -// hasActorFilter checks if the CEL filter expression contains actor-related fields. -// This is used to determine whether to use the actor_query_projection for optimal performance. -func hasActorFilter(filter string) bool { - if filter == "" { - return false - } - // Check for common actor filter patterns in CEL expressions - // This is a heuristic - doesn't need to be perfect, just helpful for optimization - return strings.Contains(filter, "actor.name") || - strings.Contains(filter, "actor.type") || - strings.Contains(filter, "actor.uid") || - // Also match if someone uses the materialized column directly - strings.Contains(filter, "actor_name") || - strings.Contains(filter, "actor_type") || - strings.Contains(filter, "actor_uid") -} - -// buildQuery constructs a ClickHouse SQL query from the query spec -func (s *ClickHouseStorage) buildQuery(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope ScopeContext) (string, []interface{}, error) { - var args []interface{} - - query := fmt.Sprintf("SELECT event_json FROM %s.audit_logs", s.config.Database) - - var conditions []string - - // Only add scope filters if not platform-wide query - if scope.Type != "platform" { - if scope.Type == "user" { - // For user scope, filter by user.uid instead of scope annotations. - // This allows querying all activity performed BY a specific user - // across all organizations and projects on the platform. - conditions = append(conditions, "user_uid = ?") - args = append(args, scope.Name) - } else { - // For organization/project scope, use the scope annotations - conditions = append(conditions, "scope_type = ?") - args = append(args, scope.Type) - - conditions = append(conditions, "scope_name = ?") - args = append(args, scope.Name) - } - } - - // Use a single reference time for both timestamps to prevent sub-second drift - // when using relative times like "now-7d" and "now" - now := time.Now() - - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "timestamp < ?") - args = append(args, endTime) - } - - if spec.Filter != "" { - celWhere, celArgs, err := cel.ConvertToClickHouseSQL(ctx, spec.Filter) - if err != nil { - // Return the error directly - it already has user-friendly messaging - return "", nil, err - } - if celWhere != "" { - processedWhere := celWhere - for i := range celArgs { - oldParam := fmt.Sprintf("{arg%d}", i+1) - processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") - } - args = append(args, celArgs...) - conditions = append(conditions, processedWhere) - } - } - - // Cursor pagination using timestamp and audit_id. - // Since timestamp is the second sort key (after toStartOfHour), we need to handle - // both hour boundaries and exact timestamps for correct pagination. - if spec.Continue != "" { - cursorTime, cursorAuditID, err := decodeCursor(spec.Continue, spec) - if err != nil { - return "", nil, err - } - - // Pagination logic: continue from where we left off - // 1. Hour bucket is earlier, OR - // 2. Same hour bucket but timestamp is earlier, OR - // 3. Same timestamp but audit_id is earlier (for tie-breaking) - conditions = append(conditions, "(toStartOfHour(timestamp) < toStartOfHour(?) OR (toStartOfHour(timestamp) = toStartOfHour(?) AND timestamp < ?) OR (timestamp = ? AND audit_id < ?))") - args = append(args, cursorTime, cursorTime, cursorTime, cursorTime, cursorAuditID) - } - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - // ORDER BY must match projection/primary key sort order for ClickHouse - // to efficiently use indexes and projections. - // Timestamp is second to ensure strict chronological ordering within each hour. - if scope.Type == "platform" { - if hasUserFilter(spec.Filter) { - // User filter present: use user_query_projection - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, user DESC, api_group DESC, resource DESC, audit_id DESC" - } else { - // No user filter: use platform_query_projection - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, api_group DESC, resource DESC, audit_id DESC" - } - } else if scope.Type == "user" { - // User-scoped: use user_uid_query_projection to filter by UID - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, user_uid DESC, api_group DESC, resource DESC, audit_id DESC" - } else { - // Tenant-scoped: match hour-bucketed primary key for efficient index use - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, scope_type DESC, scope_name DESC, user DESC, audit_id DESC" - } - - limit := spec.Limit - if limit <= 0 { - limit = 100 - } - if limit > s.config.MaxPageSize { - limit = s.config.MaxPageSize - } - - query += fmt.Sprintf(" LIMIT %d", limit+1) - - return query, args, nil -} - -// ActivityQuerySpec defines the query parameters for listing activities. -type ActivityQuerySpec struct { - // StartTime filters activities to those after this time. - StartTime string - - // EndTime filters activities to those before this time. - EndTime string - - // Search performs full-text search on summaries. - Search string - - // Filter is a CEL expression for advanced filtering. - // This is the sole filtering mechanism beyond time range and full-text search. - Filter string - - // Limit is the maximum number of results to return. - Limit int32 - - // Continue is the pagination cursor. - Continue string -} - -// ActivityQueryResult contains activities and pagination state. -type ActivityQueryResult struct { - Activities []string // JSON activity records - Continue string -} - -// QueryActivities retrieves activities matching the query specification and scope. -func (s *ClickHouseStorage) QueryActivities(ctx context.Context, spec ActivityQuerySpec, scope ScopeContext) (*ActivityQueryResult, error) { - ctx, span := tracer.Start(ctx, "clickhouse.query_activities", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", s.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.Int("query.limit", int(spec.Limit)), - ), - ) - defer span.End() - - query, args, err := s.buildActivityQuery(ctx, spec, scope) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to build query") - // Return the error directly - buildActivityQuery returns user-friendly validation errors - return nil, err - } - - klog.V(3).InfoS("Built activities ClickHouse query", - "query", query, - "argsCount", len(args), - ) - - // Add trace context - spanContext := span.SpanContext() - if spanContext.IsValid() { - traceparent := fmt.Sprintf("00-%s-%s-%02x", - spanContext.TraceID().String(), - spanContext.SpanID().String(), - spanContext.TraceFlags()) - query = fmt.Sprintf("/* traceparent: %s */ %s", traceparent, query) - } - - rows, err := s.conn.Query(ctx, query, args...) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "query execution failed") - klog.ErrorS(err, "Failed to query activities") - return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") - } - defer rows.Close() - - limit := spec.Limit - if limit <= 0 { - limit = 100 - } - if limit > s.config.MaxPageSize { - limit = s.config.MaxPageSize - } - - var activities []string - for rows.Next() { - var activityJSON string - if err := rows.Scan(&activityJSON); err != nil { - klog.ErrorS(err, "Failed to scan activity row") - return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") - } - activities = append(activities, activityJSON) - } - - if err := rows.Err(); err != nil { - klog.ErrorS(err, "Error iterating activity rows") - return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") - } - - // Check for more results - var continueToken string - if int32(len(activities)) > limit { - activities = activities[:limit] - // Create continue token from last activity timestamp - if len(activities) > 0 { - continueToken = encodeActivityCursor(activities[len(activities)-1], spec) - } - } - - span.SetAttributes( - attribute.Int("db.rows_returned", len(activities)), - attribute.Bool("query.has_more", continueToken != ""), - ) - span.SetStatus(codes.Ok, "query successful") - - return &ActivityQueryResult{ - Activities: activities, - Continue: continueToken, - }, nil -} - -// buildActivityQuery constructs a ClickHouse SQL query for activities. -func (s *ClickHouseStorage) buildActivityQuery(ctx context.Context, spec ActivityQuerySpec, scope ScopeContext) (string, []interface{}, error) { - var args []interface{} - query := fmt.Sprintf("SELECT activity_json FROM %s.activities", s.config.Database) - - var conditions []string - - // Scope filtering - if scope.Type != "platform" { - if scope.Type == "user" { - // For user scope, filter by actor_uid to show activities performed by this user - // across all organizations and projects - conditions = append(conditions, "actor_uid = ?") - args = append(args, scope.Name) - } else { - // For organization/project scope, filter by tenant - conditions = append(conditions, "tenant_type = ?") - args = append(args, scope.Type) - conditions = append(conditions, "tenant_name = ?") - args = append(args, scope.Name) - } - } - - // Time range - now := time.Now() - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "timestamp < ?") - args = append(args, endTime) - } - - // Full-text search on summary (substring matching, case-insensitive) - if spec.Search != "" { - // Split search into terms and match any term as a substring - terms := strings.Fields(spec.Search) - if len(terms) > 0 { - conditions = append(conditions, "multiSearchAnyCaseInsensitive(summary, ?) > 0") - args = append(args, terms) - } - } - - // CEL filter expression — the sole filtering mechanism beyond time range and search - if spec.Filter != "" { - celWhere, celArgs, err := cel.ConvertActivityToClickHouseSQL(ctx, spec.Filter) - if err != nil { - return "", nil, err - } - if celWhere != "" { - processedWhere := celWhere - for i := range celArgs { - oldParam := fmt.Sprintf("{arg%d}", i+1) - processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") - } - args = append(args, celArgs...) - conditions = append(conditions, processedWhere) - } - } - - // Pagination cursor aligned with the new time-bucketed ORDER BY clauses. - // The 3-level toStartOfHour pattern ensures correct pagination across hour boundaries. - if spec.Continue != "" { - cursorTime, cursorUID, err := decodeActivityCursor(spec.Continue, spec) - if err != nil { - return "", nil, err - } - // Pagination logic: continue from where we left off - // 1. Hour bucket is earlier, OR - // 2. Same hour bucket but timestamp is earlier, OR - // 3. Same timestamp but resource_uid is earlier (for tie-breaking) - conditions = append(conditions, "(toStartOfHour(timestamp) < toStartOfHour(?) OR (toStartOfHour(timestamp) = toStartOfHour(?) AND timestamp < ?) OR (timestamp = ? AND resource_uid < ?))") - args = append(args, cursorTime, cursorTime, cursorTime, cursorTime, cursorUID) - } - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - // ORDER BY must match projection/primary key sort order for ClickHouse - // to efficiently use indexes and projections. - // - // Primary key: (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) - // Projections: - // - platform_query_projection: (toStartOfHour(timestamp), timestamp, api_group, resource_kind, resource_uid) - // - actor_query_projection: (toStartOfHour(timestamp), timestamp, actor_name, api_group, resource_kind, resource_uid) - // - actor_uid_query_projection: (toStartOfHour(timestamp), timestamp, actor_uid, api_group, resource_kind, resource_uid) - if scope.Type == "platform" { - if hasActorFilter(spec.Filter) { - // Actor filter present: use actor_query_projection - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, actor_name DESC, api_group DESC, resource_kind DESC, resource_uid DESC" - } else { - // No actor filter: use platform_query_projection - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, api_group DESC, resource_kind DESC, resource_uid DESC" - } - } else if scope.Type == "user" { - // User-scoped: use actor_uid_query_projection to filter by UID - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, actor_uid DESC, api_group DESC, resource_kind DESC, resource_uid DESC" - } else { - // Tenant-scoped: match hour-bucketed primary key for efficient index use - query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, tenant_type DESC, tenant_name DESC, origin_id DESC" - } - - // Limit - limit := spec.Limit - if limit <= 0 { - limit = 100 - } - if limit > s.config.MaxPageSize { - limit = s.config.MaxPageSize - } - query += fmt.Sprintf(" LIMIT %d", limit+1) - - return query, args, nil -} - -// activityCursorData encodes pagination state for activity queries. -type activityCursorData struct { - Timestamp time.Time `json:"t"` - ResourceUID string `json:"r"` - QueryHash string `json:"h"` - IssuedAt time.Time `json:"i"` -} - -// hashActivityQueryParams creates a hash to validate cursors. -func hashActivityQueryParams(spec ActivityQuerySpec) string { - h := sha256.New() - h.Write([]byte(spec.StartTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.EndTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.Filter)) - h.Write([]byte("|")) - h.Write([]byte(spec.Search)) - h.Write([]byte("|")) - h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) - - return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) -} - -// encodeActivityCursor creates a pagination token from the last activity. -func encodeActivityCursor(lastActivityJSON string, spec ActivityQuerySpec) string { - // Extract timestamp and resource_uid from JSON - var activity struct { - Metadata struct { - CreationTimestamp string `json:"creationTimestamp"` - } `json:"metadata"` - Spec struct { - Resource struct { - UID string `json:"uid"` - } `json:"resource"` - } `json:"spec"` - } - - if err := json.Unmarshal([]byte(lastActivityJSON), &activity); err != nil { - return "" - } - - timestamp, _ := time.Parse(time.RFC3339, activity.Metadata.CreationTimestamp) - - data := activityCursorData{ - Timestamp: timestamp, - ResourceUID: activity.Spec.Resource.UID, - QueryHash: hashActivityQueryParams(spec), - IssuedAt: time.Now(), - } - - jsonData, _ := json.Marshal(data) - return base64.URLEncoding.EncodeToString(jsonData) -} - -// decodeActivityCursor validates and extracts pagination state. -func decodeActivityCursor(cursor string, spec ActivityQuerySpec) (time.Time, string, error) { - decoded, err := base64.URLEncoding.DecodeString(cursor) - if err != nil { - return time.Time{}, "", fmt.Errorf("the continue token is invalid. Remove the continue parameter to start a new query") - } - - var data activityCursorData - if err := json.Unmarshal(decoded, &data); err != nil { - return time.Time{}, "", fmt.Errorf("the continue token is invalid. Remove the continue parameter to start a new query") - } - - currentHash := hashActivityQueryParams(spec) - if data.QueryHash != currentHash { - return time.Time{}, "", fmt.Errorf("query parameters changed since the continue token was issued. Remove the continue parameter and use consistent query parameters when paginating") - } - - if time.Since(data.IssuedAt) > cursorTTL { - return time.Time{}, "", fmt.Errorf("the continue token expired after %v. Tokens are valid for %v. Remove the continue parameter to start a new query", - time.Since(data.IssuedAt).Round(time.Second), - cursorTTL, - ) - } - - return data.Timestamp, data.ResourceUID, nil -} - -// FacetFieldSpec defines a single facet field to query. -type FacetFieldSpec struct { - Field string - Limit int32 -} - -// FacetQueryResult contains the results of a facet query. -type FacetQueryResult struct { - Facets []FacetFieldResult -} - -// FacetFieldResult contains the distinct values for a single facet. -type FacetFieldResult struct { - Field string - Values []FacetValueResult -} - -// FacetValueResult represents a single distinct value with its count. -type FacetValueResult struct { - Value string - Count int64 -} - -// AuditLogFacetQuerySpec defines the parameters for an audit log facet query. -type AuditLogFacetQuerySpec struct { - // TimeRange specifies the time window for facet aggregation. - StartTime string - EndTime string - - // Filter is a CEL expression to filter audit logs before computing facets. - Filter string - - // Facets are the fields to compute distinct values for. - Facets []FacetFieldSpec -} - -// QueryAuditLogFacets retrieves distinct field values with counts for audit log faceted search. -func (s *ClickHouseStorage) QueryAuditLogFacets(ctx context.Context, spec AuditLogFacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { - ctx, span := tracer.Start(ctx, "clickhouse.query_audit_log_facets", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", s.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.Int("facet.count", len(spec.Facets)), - ), - ) - defer span.End() - - result := &FacetQueryResult{ - Facets: make([]FacetFieldResult, 0, len(spec.Facets)), - } - - // Execute each facet query - for _, facet := range spec.Facets { - facetResult, err := s.queryAuditLogFacet(ctx, facet, spec, scope) - if err != nil { - span.RecordError(err) - klog.ErrorS(err, "Failed to query audit log facet", "field", facet.Field) - // Return the error directly - queryAuditLogFacet returns user-friendly validation errors - return nil, err - } - result.Facets = append(result.Facets, *facetResult) - } - - span.SetStatus(codes.Ok, "audit log facet query successful") - return result, nil -} - -// queryAuditLogFacet executes a single facet query against the audit logs table. -func (s *ClickHouseStorage) queryAuditLogFacet(ctx context.Context, facet FacetFieldSpec, spec AuditLogFacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { - column, err := GetAuditLogFacetColumn(facet.Field) - if err != nil { - return nil, err - } - - limit := facet.Limit - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - var args []interface{} - var conditions []string - - // Scope filtering - same pattern as audit log queries - if scope.Type != "platform" { - if scope.Type == "user" { - // For user scope, filter by user_uid - conditions = append(conditions, "user_uid = ?") - args = append(args, scope.Name) - } else { - // For organization/project scope, use the scope annotations - conditions = append(conditions, "scope_type = ?") - args = append(args, scope.Type) - conditions = append(conditions, "scope_name = ?") - args = append(args, scope.Name) - } - } - - // Time range - now := time.Now() - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "timestamp < ?") - args = append(args, endTime) - } - - // CEL filter (optional) - if spec.Filter != "" { - celWhere, celArgs, err := cel.ConvertToClickHouseSQL(ctx, spec.Filter) - if err != nil { - return nil, err - } - if celWhere != "" { - processedWhere := celWhere - for i := range celArgs { - oldParam := fmt.Sprintf("{arg%d}", i+1) - processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") - } - args = append(args, celArgs...) - conditions = append(conditions, processedWhere) - } - } - - // Build query against the audit logs table - // Use toString() to ensure consistent string output for all column types (including UInt16 status_code) - query := fmt.Sprintf("SELECT toString(%s) as value, COUNT(*) as count FROM %s.audit_logs", column, s.config.Database) - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - // Group by the facet column and order by count descending, then value ascending for stability - query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, value ASC LIMIT %d", column, limit) - - klog.V(4).InfoS("Executing audit log facet query", - "field", facet.Field, - "column", column, - "query", query, - ) - - rows, err := s.conn.Query(ctx, query, args...) - if err != nil { - klog.ErrorS(err, "Failed to execute audit log facet query", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - defer rows.Close() - - result := &FacetFieldResult{ - Field: facet.Field, - Values: make([]FacetValueResult, 0), - } - - for rows.Next() { - var value string - var count uint64 - if err := rows.Scan(&value, &count); err != nil { - klog.ErrorS(err, "Failed to scan audit log facet row", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - result.Values = append(result.Values, FacetValueResult{ - Value: value, - Count: int64(count), - }) - } - - if err := rows.Err(); err != nil { - klog.ErrorS(err, "Error iterating audit log facet rows", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - - return result, nil -} - -// FacetQuerySpec defines the parameters for an activity facet query. -type FacetQuerySpec struct { - // TimeRange specifies the time window for facet aggregation. - StartTime string - EndTime string - - // Filter is a CEL expression to filter activities before computing facets. - Filter string - - // Facets are the fields to compute distinct values for. - Facets []FacetFieldSpec -} - -// QueryFacets retrieves distinct field values with counts for faceted search on activities. -func (s *ClickHouseStorage) QueryFacets(ctx context.Context, spec FacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { - ctx, span := tracer.Start(ctx, "clickhouse.query_facets", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", s.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.Int("facet.count", len(spec.Facets)), - ), - ) - defer span.End() - - result := &FacetQueryResult{ - Facets: make([]FacetFieldResult, 0, len(spec.Facets)), - } - - // Execute each facet query - for _, facet := range spec.Facets { - facetResult, err := s.queryFacet(ctx, facet, spec, scope) - if err != nil { - span.RecordError(err) - return nil, fmt.Errorf("failed to query facet %s: %w", facet.Field, err) - } - result.Facets = append(result.Facets, *facetResult) - } - - span.SetStatus(codes.Ok, "facet query successful") - return result, nil -} - -// queryFacet executes a single facet query against the activities table. -func (s *ClickHouseStorage) queryFacet(ctx context.Context, facet FacetFieldSpec, spec FacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { - column, err := GetActivityFacetColumn(facet.Field) - if err != nil { - return nil, err - } - - limit := facet.Limit - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - var args []interface{} - var conditions []string - - // Scope filtering - if scope.Type != "platform" { - if scope.Type == "user" { - // For user scope, filter by actor_uid to show activities performed by this user - // across all organizations and projects - conditions = append(conditions, "actor_uid = ?") - args = append(args, scope.Name) - } else { - // For organization/project scope, filter by tenant - conditions = append(conditions, "tenant_type = ?") - args = append(args, scope.Type) - conditions = append(conditions, "tenant_name = ?") - args = append(args, scope.Name) - } - } - - // Time range - now := time.Now() - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "timestamp < ?") - args = append(args, endTime) - } - - // CEL filter (optional) - if spec.Filter != "" { - celWhere, celArgs, err := cel.ConvertActivityToClickHouseSQL(ctx, spec.Filter) - if err != nil { - return nil, err - } - if celWhere != "" { - processedWhere := celWhere - for i := range celArgs { - oldParam := fmt.Sprintf("{arg%d}", i+1) - processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") - } - args = append(args, celArgs...) - conditions = append(conditions, processedWhere) - } - } - - query := fmt.Sprintf("SELECT %s, COUNT(*) as count FROM %s.activities", column, s.config.Database) - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - // Group by the facet column and order by count descending, then value ascending for stability - query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, %s ASC LIMIT %d", column, column, limit) - - klog.V(4).InfoS("Executing facet query", - "field", facet.Field, - "column", column, - "query", query, - ) - - rows, err := s.conn.Query(ctx, query, args...) - if err != nil { - klog.ErrorS(err, "Failed to execute facet query", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - defer rows.Close() - - result := &FacetFieldResult{ - Field: facet.Field, - Values: make([]FacetValueResult, 0), - } - - for rows.Next() { - var value string - var count uint64 - if err := rows.Scan(&value, &count); err != nil { - klog.ErrorS(err, "Failed to scan facet row", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - result.Values = append(result.Values, FacetValueResult{ - Value: value, - Count: int64(count), - }) - } - - if err := rows.Err(); err != nil { - klog.ErrorS(err, "Error iterating facet rows", "field", facet.Field) - return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) - } - - return result, nil -} +package storage + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + "k8s.io/klog/v2" + + "go.miloapis.com/activity/internal/cel" + "go.miloapis.com/activity/internal/metrics" + "go.miloapis.com/activity/internal/timeutil" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +var tracer = otel.Tracer("activity-clickhouse-storage") + +const ( + // cursorTTL limits cursor lifetime to prevent replay attacks and stale queries. + cursorTTL = 1 * time.Hour +) + +// cursorData encodes pagination state and query validation information. +type cursorData struct { + Timestamp time.Time `json:"t"` // Event timestamp for pagination + AuditID string `json:"a"` // Audit ID for tie-breaking + QueryHash string `json:"h"` // Hash of query parameters + IssuedAt time.Time `json:"i"` // When cursor was created (for expiration) +} + +// hashQueryParams creates a hash to validate cursors are used with matching queries. +// Excludes continueAfter since it changes between pagination requests. +func hashQueryParams(spec v1alpha1.AuditLogQuerySpec) string { + h := sha256.New() + h.Write([]byte(spec.StartTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.EndTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.Filter)) + h.Write([]byte("|")) + h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) + + return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) +} + +// encodeCursor creates a base64-encoded pagination token containing position and validation data. +func encodeCursor(timestamp time.Time, auditID string, spec v1alpha1.AuditLogQuerySpec) string { + data := cursorData{ + Timestamp: timestamp, + AuditID: auditID, + QueryHash: hashQueryParams(spec), + IssuedAt: time.Now(), + } + + jsonData, _ := json.Marshal(data) + return base64.URLEncoding.EncodeToString(jsonData) +} + +// ValidateCursor checks if a cursor is valid for the given query spec without extracting data. +// This is called by the API layer during validation to provide early feedback. +// Returns an error if the cursor is malformed, expired, or doesn't match the query parameters. +func ValidateCursor(cursor string, spec v1alpha1.AuditLogQuerySpec) error { + _, _, err := decodeCursor(cursor, spec) + return err +} + +// decodeCursor validates and extracts pagination state from a cursor token. +// Returns an error if the cursor is malformed, expired, or doesn't match the current query. +func decodeCursor(cursor string, spec v1alpha1.AuditLogQuerySpec) (time.Time, string, error) { + decoded, err := base64.URLEncoding.DecodeString(cursor) + if err != nil { + return time.Time{}, "", fmt.Errorf("cannot decode pagination cursor: %w", err) + } + + var data cursorData + if err := json.Unmarshal(decoded, &data); err != nil { + return time.Time{}, "", fmt.Errorf("cursor format is invalid. Start a new query") + } + + currentHash := hashQueryParams(spec) + if data.QueryHash != currentHash { + return time.Time{}, "", fmt.Errorf("cannot use cursor because query parameters changed. Start a new query without the continueAfter parameter") + } + + if data.IssuedAt.IsZero() { + return time.Time{}, "", fmt.Errorf("cursor format is invalid. Start a new query") + } + + age := time.Since(data.IssuedAt) + if age > cursorTTL { + return time.Time{}, "", fmt.Errorf("cursor expired after %v. Cursors are valid for %v. Start a new query without the continueAfter parameter", + age.Round(time.Second), + cursorTTL, + ) + } + + return data.Timestamp, data.AuditID, nil +} + +// ClickHouseConfig configures the ClickHouse connection and query limits. +type ClickHouseConfig struct { + Address string + Database string + Username string + Password string + + // TLS configuration (optional - disabled by default) + TLSEnabled bool // Enable TLS for ClickHouse connection + TLSCertFile string // Path to client certificate file + TLSKeyFile string // Path to client key file + TLSCAFile string // Path to CA certificate file + + MaxQueryWindow time.Duration // Maximum allowed time range for queries + MaxPageSize int32 // Maximum results per page +} + +// ClickHouseStorage implements audit log storage using ClickHouse. +type ClickHouseStorage struct { + conn driver.Conn + config ClickHouseConfig +} + +// NewClickHouseStorage establishes a connection to ClickHouse and validates connectivity. +func NewClickHouseStorage(config ClickHouseConfig) (*ClickHouseStorage, error) { + options := &clickhouse.Options{ + Addr: []string{config.Address}, + Auth: clickhouse.Auth{ + Database: config.Database, + Username: config.Username, + Password: config.Password, + }, + Settings: clickhouse.Settings{ + "max_execution_time": 60, + }, + DialTimeout: 5 * time.Second, + Compression: &clickhouse.Compression{ + Method: clickhouse.CompressionLZ4, + }, + } + + // Configure TLS if enabled + if config.TLSEnabled { + tlsConfig, err := loadTLSConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to load TLS configuration: %w", err) + } + options.TLS = tlsConfig + klog.V(2).Info("ClickHouse TLS enabled") + } + + conn, err := clickhouse.Open(options) + if err != nil { + return nil, fmt.Errorf("failed to connect to ClickHouse: %w", err) + } + + if err := conn.Ping(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping ClickHouse: %w", err) + } + + return &ClickHouseStorage{ + conn: conn, + config: config, + }, nil +} + +// loadTLSConfig loads TLS certificates and creates a tls.Config for ClickHouse connection. +func loadTLSConfig(config ClickHouseConfig) (*tls.Config, error) { + tlsConfig := &tls.Config{} + + // Load client certificate and key if provided + if config.TLSCertFile != "" && config.TLSKeyFile != "" { + cert, err := tls.LoadX509KeyPair(config.TLSCertFile, config.TLSKeyFile) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{cert} + klog.V(2).Infof("Loaded client certificate from %s", config.TLSCertFile) + } + + // Load CA certificate if provided + if config.TLSCAFile != "" { + caCert, err := os.ReadFile(config.TLSCAFile) + if err != nil { + return nil, fmt.Errorf("failed to read CA certificate: %w", err) + } + + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("failed to parse CA certificate") + } + tlsConfig.RootCAs = caCertPool + klog.V(2).Infof("Loaded CA certificate from %s", config.TLSCAFile) + } + + return tlsConfig, nil +} + +func (s *ClickHouseStorage) Close() error { + if s.conn != nil { + return s.conn.Close() + } + return nil +} + +// Conn returns the underlying ClickHouse connection. +func (s *ClickHouseStorage) Conn() driver.Conn { + return s.conn +} + +// Config returns the ClickHouse configuration. +func (s *ClickHouseStorage) Config() ClickHouseConfig { + return s.config +} + +func (s *ClickHouseStorage) GetMaxQueryWindow() time.Duration { + return s.config.MaxQueryWindow +} + +func (s *ClickHouseStorage) GetMaxPageSize() int32 { + return s.config.MaxPageSize +} + +// QueryResult contains audit events and pagination state. +type QueryResult struct { + Events []auditv1.Event + Continue string +} + +// ScopeContext defines the hierarchical scope boundary for audit log queries. +type ScopeContext struct { + Type string // "platform", "organization", "project", "user" + Name string // scope identifier (org name, project name, etc.) +} + +// QueryAuditLogs retrieves audit logs matching the query specification and scope. +// The spec parameter must be pre-validated by the API layer. +func (s *ClickHouseStorage) QueryAuditLogs(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope ScopeContext) (*QueryResult, error) { + ctx, span := tracer.Start(ctx, "clickhouse.query", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", s.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.Int("query.limit", int(spec.Limit)), + attribute.String("query.filter", spec.Filter), + attribute.String("query.start_time", spec.StartTime), + attribute.String("query.end_time", spec.EndTime), + ), + ) + defer span.End() + + // Start timing the overall query operation + overallStartTime := time.Now() + + query, args, err := s.buildQuery(ctx, spec, scope) + if err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("build_query").Inc() + span.RecordError(err) + span.SetStatus(codes.Error, "failed to build query") + // Return the error directly - buildQuery returns user-friendly validation errors + return nil, err + } + + klog.V(3).InfoS("Built ClickHouse query", + "query", query, + "argsCount", len(args), + ) + + // Add SQL statement to span (truncated if too long) + truncatedQuery := query + if len(query) > 1000 { + truncatedQuery = query[:1000] + "..." + } + span.SetAttributes(attribute.String("db.statement", truncatedQuery)) + + // Add trace context as SQL comment for correlation + spanContext := span.SpanContext() + if spanContext.IsValid() { + traceparent := fmt.Sprintf("00-%s-%s-%02x", + spanContext.TraceID().String(), + spanContext.SpanID().String(), + spanContext.TraceFlags()) + query = fmt.Sprintf("/* traceparent: %s */ %s", traceparent, query) + } + + // Extract trace ID for logging + traceID := span.SpanContext().TraceID().String() + spanID := span.SpanContext().SpanID().String() + + klog.InfoS("Executing ClickHouse query", + "traceID", traceID, + "spanID", spanID, + "filter", spec.Filter, + "limit", spec.Limit, + "continue", spec.Continue, + "query", truncatedQuery, + ) + + // Time the actual ClickHouse query execution + queryStartTime := time.Now() + rows, err := s.conn.Query(ctx, query, args...) + queryDuration := time.Since(queryStartTime).Seconds() + + if err != nil { + metrics.ClickHouseQueryDuration.WithLabelValues("query").Observe(queryDuration) + metrics.ClickHouseQueryTotal.WithLabelValues("error").Inc() + + // Classify error type + errorType := "unknown" + if strings.Contains(err.Error(), "connection") { + errorType = "connection" + } else if strings.Contains(err.Error(), "timeout") { + errorType = "timeout" + } else if strings.Contains(err.Error(), "syntax") { + errorType = "syntax" + } else if strings.Contains(err.Error(), "memory") { + errorType = "memory" + } else if strings.Contains(err.Error(), "parameter") { + errorType = "parameter" + } + metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() + + // Record error in span + span.RecordError(err) + span.SetStatus(codes.Error, "query execution failed") + span.SetAttributes(attribute.String("error.type", errorType)) + + // Log detailed error with trace context and query details + klog.ErrorS(err, "ClickHouse query failed", + "traceID", traceID, + "spanID", spanID, + "errorType", errorType, + "filter", spec.Filter, + "limit", spec.Limit, + "continue", spec.Continue, + "duration", queryDuration, + "query", truncatedQuery, + ) + + return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") + } + defer rows.Close() + + // Record successful query execution time + metrics.ClickHouseQueryDuration.WithLabelValues("query").Observe(queryDuration) + span.SetAttributes(attribute.Float64("db.query_duration_seconds", queryDuration)) + + // Determine the limit + limit := spec.Limit + if limit <= 0 { + limit = 100 + } + if limit > s.config.MaxPageSize { + limit = s.config.MaxPageSize + } + + var events []auditv1.Event + var unmarshalErrors int + for rows.Next() { + var eventJSON string + if err := rows.Scan(&eventJSON); err != nil { + klog.ErrorS(err, "Failed to scan row", + "traceID", traceID, + "spanID", spanID, + ) + return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") + } + + var event auditv1.Event + if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { + unmarshalErrors++ + klog.ErrorS(err, "Failed to unmarshal audit event", + "traceID", traceID, + "spanID", spanID, + ) + continue + } + + events = append(events, event) + } + + if err := rows.Err(); err != nil { + metrics.ClickHouseQueryTotal.WithLabelValues("error").Inc() + metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() + + klog.ErrorS(err, "Error iterating ClickHouse rows", + "traceID", traceID, + "spanID", spanID, + "filter", spec.Filter, + "limit", spec.Limit, + ) + + return nil, fmt.Errorf("unable to retrieve audit logs. Try again or contact support if the problem persists") + } + + if unmarshalErrors > 0 { + klog.InfoS("Query completed with unmarshal errors", + "traceID", traceID, + "spanID", spanID, + "unmarshalErrors", unmarshalErrors, + "successfulEvents", len(events), + ) + } + + // Check if we have more results (we fetched limit+1) + var continueAfter string + if int32(len(events)) > limit { + events = events[:limit] + if len(events) > 0 { + lastEvent := events[len(events)-1] + continueAfter = encodeCursor(lastEvent.StageTimestamp.Time, string(lastEvent.AuditID), spec) + } + } + + // Record successful query metrics + metrics.ClickHouseQueryTotal.WithLabelValues("success").Inc() + metrics.AuditLogQueryResults.Observe(float64(len(events))) + + // Record end-to-end query duration (includes result processing) + totalDuration := time.Since(overallStartTime).Seconds() + metrics.ClickHouseQueryDuration.WithLabelValues("total").Observe(totalDuration) + + // Add result metrics to span + span.SetAttributes( + attribute.Int("db.rows_returned", len(events)), + attribute.Bool("query.has_more", continueAfter != ""), + attribute.Float64("db.total_duration_seconds", totalDuration), + ) + span.SetStatus(codes.Ok, "query successful") + + // Log successful query completion + klog.InfoS("ClickHouse query completed successfully", + "traceID", traceID, + "spanID", spanID, + "rowsReturned", len(events), + "hasMore", continueAfter != "", + "queryDuration", queryDuration, + "totalDuration", totalDuration, + "filter", spec.Filter, + "limit", spec.Limit, + ) + + return &QueryResult{ + Events: events, + Continue: continueAfter, + }, nil +} + +// hasUserFilter checks if the CEL filter contains user-based filtering +func hasUserFilter(filter string) bool { + if filter == "" { + return false + } + // Check for common user filter patterns in CEL expressions + // This is a heuristic - doesn't need to be perfect, just helpful for optimization + return strings.Contains(filter, "user.username") || + strings.Contains(filter, "user.groups") || + strings.Contains(filter, "user.uid") || + // Also match if someone uses the materialized column directly + (strings.Contains(filter, "user") && (strings.Contains(filter, "==") || strings.Contains(filter, "!="))) +} + +// hasAPIGroupFilter checks if the CEL filter expression contains API group fields. +// Added for future use — not currently wired into buildActivityQuery. +func hasAPIGroupFilter(filter string) bool { + if filter == "" { + return false + } + return strings.Contains(filter, "resource.apiGroup") || + strings.Contains(filter, "api_group") +} + +// hasActorFilter checks if the CEL filter expression contains actor-related fields. +// This is used to determine whether to use the actor_query_projection for optimal performance. +func hasActorFilter(filter string) bool { + if filter == "" { + return false + } + // Check for common actor filter patterns in CEL expressions + // This is a heuristic - doesn't need to be perfect, just helpful for optimization + return strings.Contains(filter, "actor.name") || + strings.Contains(filter, "actor.type") || + strings.Contains(filter, "actor.uid") || + // Also match if someone uses the materialized column directly + strings.Contains(filter, "actor_name") || + strings.Contains(filter, "actor_type") || + strings.Contains(filter, "actor_uid") +} + +// buildQuery constructs a ClickHouse SQL query from the query spec +func (s *ClickHouseStorage) buildQuery(ctx context.Context, spec v1alpha1.AuditLogQuerySpec, scope ScopeContext) (string, []interface{}, error) { + var args []interface{} + + query := fmt.Sprintf("SELECT event_json FROM %s.audit_logs", s.config.Database) + + var conditions []string + + // Only add scope filters if not platform-wide query + if scope.Type != "platform" { + if scope.Type == "user" { + // For user scope, filter by user.uid instead of scope annotations. + // This allows querying all activity performed BY a specific user + // across all organizations and projects on the platform. + conditions = append(conditions, "user_uid = ?") + args = append(args, scope.Name) + } else { + // For organization/project scope, use the scope annotations + conditions = append(conditions, "scope_type = ?") + args = append(args, scope.Type) + + conditions = append(conditions, "scope_name = ?") + args = append(args, scope.Name) + } + } + + // Use a single reference time for both timestamps to prevent sub-second drift + // when using relative times like "now-7d" and "now" + now := time.Now() + + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "timestamp < ?") + args = append(args, endTime) + } + + if spec.Filter != "" { + celWhere, celArgs, err := cel.ConvertToClickHouseSQL(ctx, spec.Filter) + if err != nil { + // Return the error directly - it already has user-friendly messaging + return "", nil, err + } + if celWhere != "" { + processedWhere := celWhere + for i := range celArgs { + oldParam := fmt.Sprintf("{arg%d}", i+1) + processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") + } + args = append(args, celArgs...) + conditions = append(conditions, processedWhere) + } + } + + // Cursor pagination using timestamp and audit_id. + // Since timestamp is the second sort key (after toStartOfHour), we need to handle + // both hour boundaries and exact timestamps for correct pagination. + if spec.Continue != "" { + cursorTime, cursorAuditID, err := decodeCursor(spec.Continue, spec) + if err != nil { + return "", nil, err + } + + // Pagination logic: continue from where we left off + // 1. Hour bucket is earlier, OR + // 2. Same hour bucket but timestamp is earlier, OR + // 3. Same timestamp but audit_id is earlier (for tie-breaking) + conditions = append(conditions, "(toStartOfHour(timestamp) < toStartOfHour(?) OR (toStartOfHour(timestamp) = toStartOfHour(?) AND timestamp < ?) OR (timestamp = ? AND audit_id < ?))") + args = append(args, cursorTime, cursorTime, cursorTime, cursorTime, cursorAuditID) + } + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // ORDER BY must match projection/primary key sort order for ClickHouse + // to efficiently use indexes and projections. + // Timestamp is second to ensure strict chronological ordering within each hour. + if scope.Type == "platform" { + if hasUserFilter(spec.Filter) { + // User filter present: use user_query_projection + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, user DESC, api_group DESC, resource DESC, audit_id DESC" + } else { + // No user filter: use platform_query_projection + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, api_group DESC, resource DESC, audit_id DESC" + } + } else if scope.Type == "user" { + // User-scoped: use user_uid_query_projection to filter by UID + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, user_uid DESC, api_group DESC, resource DESC, audit_id DESC" + } else { + // Tenant-scoped: match hour-bucketed primary key for efficient index use + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, scope_type DESC, scope_name DESC, user DESC, audit_id DESC" + } + + limit := spec.Limit + if limit <= 0 { + limit = 100 + } + if limit > s.config.MaxPageSize { + limit = s.config.MaxPageSize + } + + query += fmt.Sprintf(" LIMIT %d", limit+1) + + return query, args, nil +} + +// ActivityQuerySpec defines the query parameters for listing activities. +type ActivityQuerySpec struct { + // StartTime filters activities to those after this time. + StartTime string + + // EndTime filters activities to those before this time. + EndTime string + + // Search performs full-text search on summaries. + Search string + + // Filter is a CEL expression for advanced filtering. + // This is the sole filtering mechanism beyond time range and full-text search. + Filter string + + // Limit is the maximum number of results to return. + Limit int32 + + // Continue is the pagination cursor. + Continue string +} + +// ActivityQueryResult contains activities and pagination state. +type ActivityQueryResult struct { + Activities []string // JSON activity records + Continue string +} + +// QueryActivities retrieves activities matching the query specification and scope. +func (s *ClickHouseStorage) QueryActivities(ctx context.Context, spec ActivityQuerySpec, scope ScopeContext) (*ActivityQueryResult, error) { + ctx, span := tracer.Start(ctx, "clickhouse.query_activities", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", s.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.Int("query.limit", int(spec.Limit)), + ), + ) + defer span.End() + + query, args, err := s.buildActivityQuery(ctx, spec, scope) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to build query") + // Return the error directly - buildActivityQuery returns user-friendly validation errors + return nil, err + } + + klog.V(3).InfoS("Built activities ClickHouse query", + "query", query, + "argsCount", len(args), + ) + + // Add trace context + spanContext := span.SpanContext() + if spanContext.IsValid() { + traceparent := fmt.Sprintf("00-%s-%s-%02x", + spanContext.TraceID().String(), + spanContext.SpanID().String(), + spanContext.TraceFlags()) + query = fmt.Sprintf("/* traceparent: %s */ %s", traceparent, query) + } + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "query execution failed") + klog.ErrorS(err, "Failed to query activities") + return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") + } + defer rows.Close() + + limit := spec.Limit + if limit <= 0 { + limit = 100 + } + if limit > s.config.MaxPageSize { + limit = s.config.MaxPageSize + } + + var activities []string + for rows.Next() { + var activityJSON string + if err := rows.Scan(&activityJSON); err != nil { + klog.ErrorS(err, "Failed to scan activity row") + return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") + } + activities = append(activities, activityJSON) + } + + if err := rows.Err(); err != nil { + klog.ErrorS(err, "Error iterating activity rows") + return nil, fmt.Errorf("unable to retrieve activities. Try again or contact support if the problem persists") + } + + // Check for more results + var continueToken string + if int32(len(activities)) > limit { + activities = activities[:limit] + // Create continue token from last activity timestamp + if len(activities) > 0 { + continueToken = encodeActivityCursor(activities[len(activities)-1], spec) + } + } + + span.SetAttributes( + attribute.Int("db.rows_returned", len(activities)), + attribute.Bool("query.has_more", continueToken != ""), + ) + span.SetStatus(codes.Ok, "query successful") + + return &ActivityQueryResult{ + Activities: activities, + Continue: continueToken, + }, nil +} + +// buildActivityQuery constructs a ClickHouse SQL query for activities. +func (s *ClickHouseStorage) buildActivityQuery(ctx context.Context, spec ActivityQuerySpec, scope ScopeContext) (string, []interface{}, error) { + var args []interface{} + query := fmt.Sprintf("SELECT activity_json FROM %s.activities", s.config.Database) + + var conditions []string + + // Scope filtering + if scope.Type != "platform" { + if scope.Type == "user" { + // For user scope, filter by actor_uid to show activities performed by this user + // across all organizations and projects + conditions = append(conditions, "actor_uid = ?") + args = append(args, scope.Name) + } else { + // For organization/project scope, filter by tenant + conditions = append(conditions, "tenant_type = ?") + args = append(args, scope.Type) + conditions = append(conditions, "tenant_name = ?") + args = append(args, scope.Name) + } + } + + // Time range + now := time.Now() + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "timestamp < ?") + args = append(args, endTime) + } + + // Full-text search on summary (substring matching, case-insensitive) + if spec.Search != "" { + // Split search into terms and match any term as a substring + terms := strings.Fields(spec.Search) + if len(terms) > 0 { + conditions = append(conditions, "multiSearchAnyCaseInsensitive(summary, ?) > 0") + args = append(args, terms) + } + } + + // CEL filter expression — the sole filtering mechanism beyond time range and search + if spec.Filter != "" { + celWhere, celArgs, err := cel.ConvertActivityToClickHouseSQL(ctx, spec.Filter) + if err != nil { + return "", nil, err + } + if celWhere != "" { + processedWhere := celWhere + for i := range celArgs { + oldParam := fmt.Sprintf("{arg%d}", i+1) + processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") + } + args = append(args, celArgs...) + conditions = append(conditions, processedWhere) + } + } + + // Pagination cursor aligned with the new time-bucketed ORDER BY clauses. + // The 3-level toStartOfHour pattern ensures correct pagination across hour boundaries. + if spec.Continue != "" { + cursorTime, cursorUID, err := decodeActivityCursor(spec.Continue, spec) + if err != nil { + return "", nil, err + } + // Pagination logic: continue from where we left off + // 1. Hour bucket is earlier, OR + // 2. Same hour bucket but timestamp is earlier, OR + // 3. Same timestamp but resource_uid is earlier (for tie-breaking) + conditions = append(conditions, "(toStartOfHour(timestamp) < toStartOfHour(?) OR (toStartOfHour(timestamp) = toStartOfHour(?) AND timestamp < ?) OR (timestamp = ? AND resource_uid < ?))") + args = append(args, cursorTime, cursorTime, cursorTime, cursorTime, cursorUID) + } + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // ORDER BY must match projection/primary key sort order for ClickHouse + // to efficiently use indexes and projections. + // + // Primary key: (toStartOfHour(timestamp), timestamp, tenant_type, tenant_name, origin_id) + // Projections: + // - platform_query_projection: (toStartOfHour(timestamp), timestamp, api_group, resource_kind, resource_uid) + // - actor_query_projection: (toStartOfHour(timestamp), timestamp, actor_name, api_group, resource_kind, resource_uid) + // - actor_uid_query_projection: (toStartOfHour(timestamp), timestamp, actor_uid, api_group, resource_kind, resource_uid) + if scope.Type == "platform" { + if hasActorFilter(spec.Filter) { + // Actor filter present: use actor_query_projection + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, actor_name DESC, api_group DESC, resource_kind DESC, resource_uid DESC" + } else { + // No actor filter: use platform_query_projection + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, api_group DESC, resource_kind DESC, resource_uid DESC" + } + } else if scope.Type == "user" { + // User-scoped: use actor_uid_query_projection to filter by UID + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, actor_uid DESC, api_group DESC, resource_kind DESC, resource_uid DESC" + } else { + // Tenant-scoped: match hour-bucketed primary key for efficient index use + query += " ORDER BY toStartOfHour(timestamp) DESC, timestamp DESC, tenant_type DESC, tenant_name DESC, origin_id DESC" + } + + // Limit + limit := spec.Limit + if limit <= 0 { + limit = 100 + } + if limit > s.config.MaxPageSize { + limit = s.config.MaxPageSize + } + query += fmt.Sprintf(" LIMIT %d", limit+1) + + return query, args, nil +} + +// activityCursorData encodes pagination state for activity queries. +type activityCursorData struct { + Timestamp time.Time `json:"t"` + ResourceUID string `json:"r"` + QueryHash string `json:"h"` + IssuedAt time.Time `json:"i"` +} + +// hashActivityQueryParams creates a hash to validate cursors. +func hashActivityQueryParams(spec ActivityQuerySpec) string { + h := sha256.New() + h.Write([]byte(spec.StartTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.EndTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.Filter)) + h.Write([]byte("|")) + h.Write([]byte(spec.Search)) + h.Write([]byte("|")) + h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) + + return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) +} + +// encodeActivityCursor creates a pagination token from the last activity. +func encodeActivityCursor(lastActivityJSON string, spec ActivityQuerySpec) string { + // Extract timestamp and resource_uid from JSON + var activity struct { + Metadata struct { + CreationTimestamp string `json:"creationTimestamp"` + } `json:"metadata"` + Spec struct { + Resource struct { + UID string `json:"uid"` + } `json:"resource"` + } `json:"spec"` + } + + if err := json.Unmarshal([]byte(lastActivityJSON), &activity); err != nil { + return "" + } + + timestamp, _ := time.Parse(time.RFC3339, activity.Metadata.CreationTimestamp) + + data := activityCursorData{ + Timestamp: timestamp, + ResourceUID: activity.Spec.Resource.UID, + QueryHash: hashActivityQueryParams(spec), + IssuedAt: time.Now(), + } + + jsonData, _ := json.Marshal(data) + return base64.URLEncoding.EncodeToString(jsonData) +} + +// decodeActivityCursor validates and extracts pagination state. +func decodeActivityCursor(cursor string, spec ActivityQuerySpec) (time.Time, string, error) { + decoded, err := base64.URLEncoding.DecodeString(cursor) + if err != nil { + return time.Time{}, "", fmt.Errorf("the continue token is invalid. Remove the continue parameter to start a new query") + } + + var data activityCursorData + if err := json.Unmarshal(decoded, &data); err != nil { + return time.Time{}, "", fmt.Errorf("the continue token is invalid. Remove the continue parameter to start a new query") + } + + currentHash := hashActivityQueryParams(spec) + if data.QueryHash != currentHash { + return time.Time{}, "", fmt.Errorf("query parameters changed since the continue token was issued. Remove the continue parameter and use consistent query parameters when paginating") + } + + if time.Since(data.IssuedAt) > cursorTTL { + return time.Time{}, "", fmt.Errorf("the continue token expired after %v. Tokens are valid for %v. Remove the continue parameter to start a new query", + time.Since(data.IssuedAt).Round(time.Second), + cursorTTL, + ) + } + + return data.Timestamp, data.ResourceUID, nil +} + +// FacetFieldSpec defines a single facet field to query. +type FacetFieldSpec struct { + Field string + Limit int32 +} + +// FacetQueryResult contains the results of a facet query. +type FacetQueryResult struct { + Facets []FacetFieldResult +} + +// FacetFieldResult contains the distinct values for a single facet. +type FacetFieldResult struct { + Field string + Values []FacetValueResult +} + +// FacetValueResult represents a single distinct value with its count. +type FacetValueResult struct { + Value string + Count int64 +} + +// AuditLogFacetQuerySpec defines the parameters for an audit log facet query. +type AuditLogFacetQuerySpec struct { + // TimeRange specifies the time window for facet aggregation. + StartTime string + EndTime string + + // Filter is a CEL expression to filter audit logs before computing facets. + Filter string + + // Facets are the fields to compute distinct values for. + Facets []FacetFieldSpec +} + +// QueryAuditLogFacets retrieves distinct field values with counts for audit log faceted search. +func (s *ClickHouseStorage) QueryAuditLogFacets(ctx context.Context, spec AuditLogFacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { + ctx, span := tracer.Start(ctx, "clickhouse.query_audit_log_facets", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", s.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.Int("facet.count", len(spec.Facets)), + ), + ) + defer span.End() + + result := &FacetQueryResult{ + Facets: make([]FacetFieldResult, 0, len(spec.Facets)), + } + + // Execute each facet query + for _, facet := range spec.Facets { + facetResult, err := s.queryAuditLogFacet(ctx, facet, spec, scope) + if err != nil { + span.RecordError(err) + klog.ErrorS(err, "Failed to query audit log facet", "field", facet.Field) + // Return the error directly - queryAuditLogFacet returns user-friendly validation errors + return nil, err + } + result.Facets = append(result.Facets, *facetResult) + } + + span.SetStatus(codes.Ok, "audit log facet query successful") + return result, nil +} + +// queryAuditLogFacet executes a single facet query against the audit logs table. +func (s *ClickHouseStorage) queryAuditLogFacet(ctx context.Context, facet FacetFieldSpec, spec AuditLogFacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { + column, err := GetAuditLogFacetColumn(facet.Field) + if err != nil { + return nil, err + } + + limit := facet.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + var args []interface{} + var conditions []string + + // Scope filtering - same pattern as audit log queries + if scope.Type != "platform" { + if scope.Type == "user" { + // For user scope, filter by user_uid + conditions = append(conditions, "user_uid = ?") + args = append(args, scope.Name) + } else { + // For organization/project scope, use the scope annotations + conditions = append(conditions, "scope_type = ?") + args = append(args, scope.Type) + conditions = append(conditions, "scope_name = ?") + args = append(args, scope.Name) + } + } + + // Time range + now := time.Now() + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "timestamp < ?") + args = append(args, endTime) + } + + // CEL filter (optional) + if spec.Filter != "" { + celWhere, celArgs, err := cel.ConvertToClickHouseSQL(ctx, spec.Filter) + if err != nil { + return nil, err + } + if celWhere != "" { + processedWhere := celWhere + for i := range celArgs { + oldParam := fmt.Sprintf("{arg%d}", i+1) + processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") + } + args = append(args, celArgs...) + conditions = append(conditions, processedWhere) + } + } + + // Build query against the audit logs table + // Use toString() to ensure consistent string output for all column types (including UInt16 status_code) + query := fmt.Sprintf("SELECT toString(%s) as value, COUNT(*) as count FROM %s.audit_logs", column, s.config.Database) + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // Group by the facet column and order by count descending, then value ascending for stability + query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, value ASC LIMIT %d", column, limit) + + klog.V(4).InfoS("Executing audit log facet query", + "field", facet.Field, + "column", column, + "query", query, + ) + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + klog.ErrorS(err, "Failed to execute audit log facet query", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + defer rows.Close() + + result := &FacetFieldResult{ + Field: facet.Field, + Values: make([]FacetValueResult, 0), + } + + for rows.Next() { + var value string + var count uint64 + if err := rows.Scan(&value, &count); err != nil { + klog.ErrorS(err, "Failed to scan audit log facet row", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + result.Values = append(result.Values, FacetValueResult{ + Value: value, + Count: int64(count), + }) + } + + if err := rows.Err(); err != nil { + klog.ErrorS(err, "Error iterating audit log facet rows", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + + return result, nil +} + +// FacetQuerySpec defines the parameters for an activity facet query. +type FacetQuerySpec struct { + // TimeRange specifies the time window for facet aggregation. + StartTime string + EndTime string + + // Filter is a CEL expression to filter activities before computing facets. + Filter string + + // Facets are the fields to compute distinct values for. + Facets []FacetFieldSpec +} + +// QueryFacets retrieves distinct field values with counts for faceted search on activities. +func (s *ClickHouseStorage) QueryFacets(ctx context.Context, spec FacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { + ctx, span := tracer.Start(ctx, "clickhouse.query_facets", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", s.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.Int("facet.count", len(spec.Facets)), + ), + ) + defer span.End() + + result := &FacetQueryResult{ + Facets: make([]FacetFieldResult, 0, len(spec.Facets)), + } + + // Execute each facet query + for _, facet := range spec.Facets { + facetResult, err := s.queryFacet(ctx, facet, spec, scope) + if err != nil { + span.RecordError(err) + return nil, fmt.Errorf("failed to query facet %s: %w", facet.Field, err) + } + result.Facets = append(result.Facets, *facetResult) + } + + span.SetStatus(codes.Ok, "facet query successful") + return result, nil +} + +// queryFacet executes a single facet query against the activities table. +func (s *ClickHouseStorage) queryFacet(ctx context.Context, facet FacetFieldSpec, spec FacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { + column, err := GetActivityFacetColumn(facet.Field) + if err != nil { + return nil, err + } + + limit := facet.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + var args []interface{} + var conditions []string + + // Scope filtering + if scope.Type != "platform" { + if scope.Type == "user" { + // For user scope, filter by actor_uid to show activities performed by this user + // across all organizations and projects + conditions = append(conditions, "actor_uid = ?") + args = append(args, scope.Name) + } else { + // For organization/project scope, filter by tenant + conditions = append(conditions, "tenant_type = ?") + args = append(args, scope.Type) + conditions = append(conditions, "tenant_name = ?") + args = append(args, scope.Name) + } + } + + // Time range + now := time.Now() + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "timestamp < ?") + args = append(args, endTime) + } + + // CEL filter (optional) + if spec.Filter != "" { + celWhere, celArgs, err := cel.ConvertActivityToClickHouseSQL(ctx, spec.Filter) + if err != nil { + return nil, err + } + if celWhere != "" { + processedWhere := celWhere + for i := range celArgs { + oldParam := fmt.Sprintf("{arg%d}", i+1) + processedWhere = strings.ReplaceAll(processedWhere, oldParam, "?") + } + args = append(args, celArgs...) + conditions = append(conditions, processedWhere) + } + } + + query := fmt.Sprintf("SELECT %s, COUNT(*) as count FROM %s.activities", column, s.config.Database) + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // Group by the facet column and order by count descending, then value ascending for stability + query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, %s ASC LIMIT %d", column, column, limit) + + klog.V(4).InfoS("Executing facet query", + "field", facet.Field, + "column", column, + "query", query, + ) + + rows, err := s.conn.Query(ctx, query, args...) + if err != nil { + klog.ErrorS(err, "Failed to execute facet query", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + defer rows.Close() + + result := &FacetFieldResult{ + Field: facet.Field, + Values: make([]FacetValueResult, 0), + } + + for rows.Next() { + var value string + var count uint64 + if err := rows.Scan(&value, &count); err != nil { + klog.ErrorS(err, "Failed to scan facet row", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + result.Values = append(result.Values, FacetValueResult{ + Value: value, + Count: int64(count), + }) + } + + if err := rows.Err(); err != nil { + klog.ErrorS(err, "Error iterating facet rows", "field", facet.Field) + return nil, fmt.Errorf("unable to retrieve facet data for field '%s'. Try again or contact support if the problem persists", facet.Field) + } + + return result, nil +} diff --git a/internal/storage/event_query_clickhouse.go b/internal/storage/event_query_clickhouse.go index 6dc8500f..c7438d9c 100644 --- a/internal/storage/event_query_clickhouse.go +++ b/internal/storage/event_query_clickhouse.go @@ -1,382 +1,382 @@ -package storage - -import ( - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - eventsv1 "k8s.io/api/events/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/klog/v2" - - "go.miloapis.com/activity/internal/metrics" - "go.miloapis.com/activity/internal/timeutil" - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" -) - -const ( - // eventQueryMaxWindow is the maximum allowed query window for EventQuery. - // Unlike the native Events list (24h), EventQuery supports up to 60 days. - eventQueryMaxWindow = 60 * 24 * time.Hour - - // eventQueryDefaultLimit is the default page size for EventQuery results. - eventQueryDefaultLimit = int32(100) - - // eventQueryMaxLimit is the maximum page size for EventQuery results. - eventQueryMaxLimit = int32(1000) -) - -// EventQueryBackend defines the storage interface for EventQuery operations. -type EventQueryBackend interface { - QueryEvents(ctx context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (*EventQueryResult, error) - GetMaxQueryWindow() time.Duration - GetMaxPageSize() int32 -} - -// EventQueryResult contains events and pagination state from an EventQuery. -type EventQueryResult struct { - Events []v1alpha1.EventRecord - Continue string -} - -// ClickHouseEventQueryBackend implements EventQueryBackend using ClickHouse. -// Unlike ClickHouseEventsBackend (which enforces a 24-hour window on List), -// this backend supports up to 60 days of history with explicit time bounds -// from the EventQuerySpec. -type ClickHouseEventQueryBackend struct { - conn driver.Conn - config ClickHouseEventsConfig -} - -// NewClickHouseEventQueryBackend creates a new ClickHouse-backed EventQuery storage. -func NewClickHouseEventQueryBackend(conn driver.Conn, config ClickHouseEventsConfig) *ClickHouseEventQueryBackend { - return &ClickHouseEventQueryBackend{ - conn: conn, - config: config, - } -} - -// GetMaxQueryWindow returns the maximum allowed query time window (60 days). -func (b *ClickHouseEventQueryBackend) GetMaxQueryWindow() time.Duration { - return eventQueryMaxWindow -} - -// GetMaxPageSize returns the maximum allowed page size (1000). -func (b *ClickHouseEventQueryBackend) GetMaxPageSize() int32 { - return eventQueryMaxLimit -} - -// QueryEvents retrieves Kubernetes Events matching the query specification and scope. -// The spec must be pre-validated by the API layer (startTime, endTime required, -// window <= 60 days, limit <= 1000). -func (b *ClickHouseEventQueryBackend) QueryEvents(ctx context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (*EventQueryResult, error) { - query, args, err := b.buildQuery(ctx, spec, scope) - if err != nil { - return nil, err - } - - klog.V(3).InfoS("Executing EventQuery ClickHouse query", - "query", query, - "argsCount", len(args), - ) - - rows, err := b.conn.Query(ctx, query, args...) - if err != nil { - // Classify error type - errorType := "unknown" - errStr := err.Error() - if strings.Contains(errStr, "connection") { - errorType = "connection" - } else if strings.Contains(errStr, "timeout") { - errorType = "timeout" - } else if strings.Contains(errStr, "syntax") { - errorType = "syntax" - } - metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() - - klog.ErrorS(err, "EventQuery ClickHouse query failed", - "fieldSelector", spec.FieldSelector, - "namespace", spec.Namespace, - "limit", spec.Limit, - "errorType", errorType, - ) - return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") - } - defer rows.Close() - - limit := resolveEventQueryLimit(spec.Limit) - - var events []v1alpha1.EventRecord - for rows.Next() { - var eventJSON string - if err := rows.Scan(&eventJSON); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("scan").Inc() - klog.ErrorS(err, "Failed to scan EventQuery row") - return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") - } - - var event eventsv1.Event - if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { - klog.ErrorS(err, "Failed to unmarshal event in EventQuery, skipping") - continue - } - - // Convert eventsv1.Event to v1alpha1.EventRecord - events = append(events, convertEventsV1ToEventRecord(&event)) - } - - if err := rows.Err(); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() - klog.ErrorS(err, "Error iterating EventQuery rows") - return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") - } - - // Check whether more results exist (we fetched limit+1) - var continueToken string - if int32(len(events)) > limit { - events = events[:limit] - if len(events) > 0 { - lastEvent := events[len(events)-1] - continueToken = encodeEventQueryCursor(lastEvent, spec) - } - } - - klog.V(4).InfoS("EventQuery completed", - "rowsReturned", len(events), - "hasMore", continueToken != "", - "namespace", spec.Namespace, - "limit", spec.Limit, - ) - - return &EventQueryResult{ - Events: events, - Continue: continueToken, - }, nil -} - -// buildQuery constructs the ClickHouse SQL query from the EventQuerySpec. -func (b *ClickHouseEventQueryBackend) buildQuery(_ context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (string, []interface{}, error) { - var conditions []string - var args []interface{} - - // Scope filtering — events carry scope annotations set at write time - scopeConds, scopeArgs := b.buildScopeConditions(scope) - conditions = append(conditions, scopeConds...) - args = append(args, scopeArgs...) - - // Namespace filter (optional) - if spec.Namespace != "" { - conditions = append(conditions, "namespace = ?") - args = append(args, spec.Namespace) - } - - // Time range — use a single reference time to prevent sub-second drift - now := time.Now() - - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "last_timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return "", nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "last_timestamp < ?") - args = append(args, endTime) - } - - // Field selector (optional) — translates standard K8s field selectors to WHERE clauses - if spec.FieldSelector != "" { - terms, err := ParseFieldSelector(spec.FieldSelector) - if err != nil { - return "", nil, fmt.Errorf("invalid fieldSelector: %w", err) - } - fieldConds, fieldArgs := FieldSelectorTermsToSQL(terms) - conditions = append(conditions, fieldConds...) - args = append(args, fieldArgs...) - } - - // Pagination cursor — decode offset from opaque continue token - if spec.Continue != "" { - offset, err := decodeEventQueryCursor(spec.Continue, spec) - if err != nil { - return "", nil, err - } - // Offset-based pagination: skip rows already returned in previous pages - limit := resolveEventQueryLimit(spec.Limit) - query := fmt.Sprintf("SELECT event_json FROM %s.%s", b.config.Database, "k8s_events") - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - query += " ORDER BY last_timestamp DESC, namespace, name" - query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit+1, offset) - return query, args, nil - } - - query := fmt.Sprintf("SELECT event_json FROM %s.%s", b.config.Database, "k8s_events") - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - query += " ORDER BY last_timestamp DESC, namespace, name" - - limit := resolveEventQueryLimit(spec.Limit) - query += fmt.Sprintf(" LIMIT %d", limit+1) - - return query, args, nil -} - -// buildScopeConditions returns WHERE conditions for scope-based multi-tenancy filtering. -func (b *ClickHouseEventQueryBackend) buildScopeConditions(scope ScopeContext) ([]string, []interface{}) { - var conditions []string - var args []interface{} - - if scope.Type == "" || scope.Type == "platform" { - // Platform scope sees all events across all tenants - return conditions, args - } - - switch scope.Type { - case "organization", "project": - conditions = append(conditions, "scope_type = ?", "scope_name = ?") - args = append(args, scope.Type, scope.Name) - case "user": - // User scope falls back to organization/project filtering for events - // since events don't carry user-level attribution the same way audit logs do. - conditions = append(conditions, "scope_type = ?", "scope_name = ?") - args = append(args, scope.Type, scope.Name) - } - - return conditions, args -} - -// resolveEventQueryLimit applies default and maximum bounds to the requested limit. -func resolveEventQueryLimit(requested int32) int32 { - if requested <= 0 { - return eventQueryDefaultLimit - } - if requested > eventQueryMaxLimit { - return eventQueryMaxLimit - } - return requested -} - -// eventQueryCursorData encodes pagination state for EventQuery. -// Uses offset-based pagination because events lack a stable monotonic cursor field -// analogous to audit_id in audit logs. -type eventQueryCursorData struct { - Offset int32 `json:"o"` // Number of rows to skip - QueryHash string `json:"h"` // Hash of query parameters for validation - IssuedAt time.Time `json:"i"` // When cursor was created (for expiration) -} - -// hashEventQueryParams creates a stable hash of the query parameters. -// Excludes Continue since it changes between pagination requests. -func hashEventQueryParams(spec v1alpha1.EventQuerySpec) string { - h := sha256.New() - h.Write([]byte(spec.StartTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.EndTime)) - h.Write([]byte("|")) - h.Write([]byte(spec.Namespace)) - h.Write([]byte("|")) - h.Write([]byte(spec.FieldSelector)) - h.Write([]byte("|")) - h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) - return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) -} - -// encodeEventQueryCursor creates a base64-encoded pagination token. -// The offset is computed from the position of the last event returned. -func encodeEventQueryCursor(lastEvent v1alpha1.EventRecord, spec v1alpha1.EventQuerySpec) string { - // Determine the current page's starting offset from the Continue token, if any - currentOffset := int32(0) - if spec.Continue != "" { - if offset, err := decodeEventQueryCursor(spec.Continue, spec); err == nil { - currentOffset = offset - } - } - - limit := resolveEventQueryLimit(spec.Limit) - nextOffset := currentOffset + limit - - data := eventQueryCursorData{ - Offset: nextOffset, - QueryHash: hashEventQueryParams(spec), - IssuedAt: time.Now(), - } - - jsonData, _ := json.Marshal(data) - return base64.URLEncoding.EncodeToString(jsonData) -} - -// decodeEventQueryCursor validates and extracts the offset from a cursor token. -// Returns an error if the cursor is malformed, expired, or parameters changed. -func decodeEventQueryCursor(cursor string, spec v1alpha1.EventQuerySpec) (int32, error) { - decoded, err := base64.URLEncoding.DecodeString(cursor) - if err != nil { - return 0, fmt.Errorf("cannot decode pagination cursor: %w", err) - } - - var data eventQueryCursorData - if err := json.Unmarshal(decoded, &data); err != nil { - return 0, fmt.Errorf("cursor format is invalid. Start a new query") - } - - currentHash := hashEventQueryParams(spec) - if data.QueryHash != currentHash { - return 0, fmt.Errorf("cannot use cursor because query parameters changed. Start a new query without the continue parameter") - } - - if data.IssuedAt.IsZero() { - return 0, fmt.Errorf("cursor format is invalid. Start a new query") - } - - age := time.Since(data.IssuedAt) - if age > cursorTTL { - return 0, fmt.Errorf("cursor expired after %v. Cursors are valid for %v. Start a new query without the continue parameter", - age.Round(time.Second), - cursorTTL, - ) - } - - return data.Offset, nil -} - -// ValidateEventQueryCursor checks if a cursor is valid for the given EventQuerySpec. -// Called by the API layer during validation to provide early feedback. -func ValidateEventQueryCursor(cursor string, spec v1alpha1.EventQuerySpec) error { - _, err := decodeEventQueryCursor(cursor, spec) - return err -} - -// GetEventQueryNotFoundError returns a standard not-found error for EventQuery resources. -// Exported for use by the REST handler. -func GetEventQueryNotFoundError(name string) error { - return errors.NewNotFound(v1alpha1.Resource("eventqueries"), name) -} - -// convertEventsV1ToEventRecord wraps an eventsv1.Event in an EventRecord. -// This provides a wrapper type registered under activity.miloapis.com/v1alpha1 -// to avoid OpenAPI GVK conflicts while preserving full event data. -func convertEventsV1ToEventRecord(event *eventsv1.Event) v1alpha1.EventRecord { - return v1alpha1.EventRecord{ - TypeMeta: metav1.TypeMeta{ - APIVersion: v1alpha1.SchemeGroupVersion.String(), - Kind: "EventRecord", - }, - ObjectMeta: event.ObjectMeta, - Event: *event, - } -} +package storage + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + eventsv1 "k8s.io/api/events/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/klog/v2" + + "go.miloapis.com/activity/internal/metrics" + "go.miloapis.com/activity/internal/timeutil" + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" +) + +const ( + // eventQueryMaxWindow is the maximum allowed query window for EventQuery. + // Unlike the native Events list (24h), EventQuery supports up to 60 days. + eventQueryMaxWindow = 60 * 24 * time.Hour + + // eventQueryDefaultLimit is the default page size for EventQuery results. + eventQueryDefaultLimit = int32(100) + + // eventQueryMaxLimit is the maximum page size for EventQuery results. + eventQueryMaxLimit = int32(1000) +) + +// EventQueryBackend defines the storage interface for EventQuery operations. +type EventQueryBackend interface { + QueryEvents(ctx context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (*EventQueryResult, error) + GetMaxQueryWindow() time.Duration + GetMaxPageSize() int32 +} + +// EventQueryResult contains events and pagination state from an EventQuery. +type EventQueryResult struct { + Events []v1alpha1.EventRecord + Continue string +} + +// ClickHouseEventQueryBackend implements EventQueryBackend using ClickHouse. +// Unlike ClickHouseEventsBackend (which enforces a 24-hour window on List), +// this backend supports up to 60 days of history with explicit time bounds +// from the EventQuerySpec. +type ClickHouseEventQueryBackend struct { + conn driver.Conn + config ClickHouseEventsConfig +} + +// NewClickHouseEventQueryBackend creates a new ClickHouse-backed EventQuery storage. +func NewClickHouseEventQueryBackend(conn driver.Conn, config ClickHouseEventsConfig) *ClickHouseEventQueryBackend { + return &ClickHouseEventQueryBackend{ + conn: conn, + config: config, + } +} + +// GetMaxQueryWindow returns the maximum allowed query time window (60 days). +func (b *ClickHouseEventQueryBackend) GetMaxQueryWindow() time.Duration { + return eventQueryMaxWindow +} + +// GetMaxPageSize returns the maximum allowed page size (1000). +func (b *ClickHouseEventQueryBackend) GetMaxPageSize() int32 { + return eventQueryMaxLimit +} + +// QueryEvents retrieves Kubernetes Events matching the query specification and scope. +// The spec must be pre-validated by the API layer (startTime, endTime required, +// window <= 60 days, limit <= 1000). +func (b *ClickHouseEventQueryBackend) QueryEvents(ctx context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (*EventQueryResult, error) { + query, args, err := b.buildQuery(ctx, spec, scope) + if err != nil { + return nil, err + } + + klog.V(3).InfoS("Executing EventQuery ClickHouse query", + "query", query, + "argsCount", len(args), + ) + + rows, err := b.conn.Query(ctx, query, args...) + if err != nil { + // Classify error type + errorType := "unknown" + errStr := err.Error() + if strings.Contains(errStr, "connection") { + errorType = "connection" + } else if strings.Contains(errStr, "timeout") { + errorType = "timeout" + } else if strings.Contains(errStr, "syntax") { + errorType = "syntax" + } + metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() + + klog.ErrorS(err, "EventQuery ClickHouse query failed", + "fieldSelector", spec.FieldSelector, + "namespace", spec.Namespace, + "limit", spec.Limit, + "errorType", errorType, + ) + return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") + } + defer rows.Close() + + limit := resolveEventQueryLimit(spec.Limit) + + var events []v1alpha1.EventRecord + for rows.Next() { + var eventJSON string + if err := rows.Scan(&eventJSON); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("scan").Inc() + klog.ErrorS(err, "Failed to scan EventQuery row") + return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") + } + + var event eventsv1.Event + if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { + klog.ErrorS(err, "Failed to unmarshal event in EventQuery, skipping") + continue + } + + // Convert eventsv1.Event to v1alpha1.EventRecord + events = append(events, convertEventsV1ToEventRecord(&event)) + } + + if err := rows.Err(); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() + klog.ErrorS(err, "Error iterating EventQuery rows") + return nil, fmt.Errorf("unable to retrieve events. Try again or contact support if the problem persists") + } + + // Check whether more results exist (we fetched limit+1) + var continueToken string + if int32(len(events)) > limit { + events = events[:limit] + if len(events) > 0 { + lastEvent := events[len(events)-1] + continueToken = encodeEventQueryCursor(lastEvent, spec) + } + } + + klog.V(4).InfoS("EventQuery completed", + "rowsReturned", len(events), + "hasMore", continueToken != "", + "namespace", spec.Namespace, + "limit", spec.Limit, + ) + + return &EventQueryResult{ + Events: events, + Continue: continueToken, + }, nil +} + +// buildQuery constructs the ClickHouse SQL query from the EventQuerySpec. +func (b *ClickHouseEventQueryBackend) buildQuery(_ context.Context, spec v1alpha1.EventQuerySpec, scope ScopeContext) (string, []interface{}, error) { + var conditions []string + var args []interface{} + + // Scope filtering — events carry scope annotations set at write time + scopeConds, scopeArgs := b.buildScopeConditions(scope) + conditions = append(conditions, scopeConds...) + args = append(args, scopeArgs...) + + // Namespace filter (optional) + if spec.Namespace != "" { + conditions = append(conditions, "namespace = ?") + args = append(args, spec.Namespace) + } + + // Time range — use a single reference time to prevent sub-second drift + now := time.Now() + + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "last_timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return "", nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "last_timestamp < ?") + args = append(args, endTime) + } + + // Field selector (optional) — translates standard K8s field selectors to WHERE clauses + if spec.FieldSelector != "" { + terms, err := ParseFieldSelector(spec.FieldSelector) + if err != nil { + return "", nil, fmt.Errorf("invalid fieldSelector: %w", err) + } + fieldConds, fieldArgs := FieldSelectorTermsToSQL(terms) + conditions = append(conditions, fieldConds...) + args = append(args, fieldArgs...) + } + + // Pagination cursor — decode offset from opaque continue token + if spec.Continue != "" { + offset, err := decodeEventQueryCursor(spec.Continue, spec) + if err != nil { + return "", nil, err + } + // Offset-based pagination: skip rows already returned in previous pages + limit := resolveEventQueryLimit(spec.Limit) + query := fmt.Sprintf("SELECT event_json FROM %s.%s", b.config.Database, "k8s_events") + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + query += " ORDER BY last_timestamp DESC, namespace, name" + query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit+1, offset) + return query, args, nil + } + + query := fmt.Sprintf("SELECT event_json FROM %s.%s", b.config.Database, "k8s_events") + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + query += " ORDER BY last_timestamp DESC, namespace, name" + + limit := resolveEventQueryLimit(spec.Limit) + query += fmt.Sprintf(" LIMIT %d", limit+1) + + return query, args, nil +} + +// buildScopeConditions returns WHERE conditions for scope-based multi-tenancy filtering. +func (b *ClickHouseEventQueryBackend) buildScopeConditions(scope ScopeContext) ([]string, []interface{}) { + var conditions []string + var args []interface{} + + if scope.Type == "" || scope.Type == "platform" { + // Platform scope sees all events across all tenants + return conditions, args + } + + switch scope.Type { + case "organization", "project": + conditions = append(conditions, "scope_type = ?", "scope_name = ?") + args = append(args, scope.Type, scope.Name) + case "user": + // User scope falls back to organization/project filtering for events + // since events don't carry user-level attribution the same way audit logs do. + conditions = append(conditions, "scope_type = ?", "scope_name = ?") + args = append(args, scope.Type, scope.Name) + } + + return conditions, args +} + +// resolveEventQueryLimit applies default and maximum bounds to the requested limit. +func resolveEventQueryLimit(requested int32) int32 { + if requested <= 0 { + return eventQueryDefaultLimit + } + if requested > eventQueryMaxLimit { + return eventQueryMaxLimit + } + return requested +} + +// eventQueryCursorData encodes pagination state for EventQuery. +// Uses offset-based pagination because events lack a stable monotonic cursor field +// analogous to audit_id in audit logs. +type eventQueryCursorData struct { + Offset int32 `json:"o"` // Number of rows to skip + QueryHash string `json:"h"` // Hash of query parameters for validation + IssuedAt time.Time `json:"i"` // When cursor was created (for expiration) +} + +// hashEventQueryParams creates a stable hash of the query parameters. +// Excludes Continue since it changes between pagination requests. +func hashEventQueryParams(spec v1alpha1.EventQuerySpec) string { + h := sha256.New() + h.Write([]byte(spec.StartTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.EndTime)) + h.Write([]byte("|")) + h.Write([]byte(spec.Namespace)) + h.Write([]byte("|")) + h.Write([]byte(spec.FieldSelector)) + h.Write([]byte("|")) + h.Write([]byte(fmt.Sprintf("%d", spec.Limit))) + return base64.URLEncoding.EncodeToString(h.Sum(nil)[:16]) +} + +// encodeEventQueryCursor creates a base64-encoded pagination token. +// The offset is computed from the position of the last event returned. +func encodeEventQueryCursor(lastEvent v1alpha1.EventRecord, spec v1alpha1.EventQuerySpec) string { + // Determine the current page's starting offset from the Continue token, if any + currentOffset := int32(0) + if spec.Continue != "" { + if offset, err := decodeEventQueryCursor(spec.Continue, spec); err == nil { + currentOffset = offset + } + } + + limit := resolveEventQueryLimit(spec.Limit) + nextOffset := currentOffset + limit + + data := eventQueryCursorData{ + Offset: nextOffset, + QueryHash: hashEventQueryParams(spec), + IssuedAt: time.Now(), + } + + jsonData, _ := json.Marshal(data) + return base64.URLEncoding.EncodeToString(jsonData) +} + +// decodeEventQueryCursor validates and extracts the offset from a cursor token. +// Returns an error if the cursor is malformed, expired, or parameters changed. +func decodeEventQueryCursor(cursor string, spec v1alpha1.EventQuerySpec) (int32, error) { + decoded, err := base64.URLEncoding.DecodeString(cursor) + if err != nil { + return 0, fmt.Errorf("cannot decode pagination cursor: %w", err) + } + + var data eventQueryCursorData + if err := json.Unmarshal(decoded, &data); err != nil { + return 0, fmt.Errorf("cursor format is invalid. Start a new query") + } + + currentHash := hashEventQueryParams(spec) + if data.QueryHash != currentHash { + return 0, fmt.Errorf("cannot use cursor because query parameters changed. Start a new query without the continue parameter") + } + + if data.IssuedAt.IsZero() { + return 0, fmt.Errorf("cursor format is invalid. Start a new query") + } + + age := time.Since(data.IssuedAt) + if age > cursorTTL { + return 0, fmt.Errorf("cursor expired after %v. Cursors are valid for %v. Start a new query without the continue parameter", + age.Round(time.Second), + cursorTTL, + ) + } + + return data.Offset, nil +} + +// ValidateEventQueryCursor checks if a cursor is valid for the given EventQuerySpec. +// Called by the API layer during validation to provide early feedback. +func ValidateEventQueryCursor(cursor string, spec v1alpha1.EventQuerySpec) error { + _, err := decodeEventQueryCursor(cursor, spec) + return err +} + +// GetEventQueryNotFoundError returns a standard not-found error for EventQuery resources. +// Exported for use by the REST handler. +func GetEventQueryNotFoundError(name string) error { + return errors.NewNotFound(v1alpha1.Resource("eventqueries"), name) +} + +// convertEventsV1ToEventRecord wraps an eventsv1.Event in an EventRecord. +// This provides a wrapper type registered under activity.miloapis.com/v1alpha1 +// to avoid OpenAPI GVK conflicts while preserving full event data. +func convertEventsV1ToEventRecord(event *eventsv1.Event) v1alpha1.EventRecord { + return v1alpha1.EventRecord{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v1alpha1.SchemeGroupVersion.String(), + Kind: "EventRecord", + }, + ObjectMeta: event.ObjectMeta, + Event: *event, + } +} diff --git a/internal/storage/events_clickhouse.go b/internal/storage/events_clickhouse.go index 734bb60c..22ef08f0 100644 --- a/internal/storage/events_clickhouse.go +++ b/internal/storage/events_clickhouse.go @@ -1,592 +1,592 @@ -package storage - -import ( - "context" - "encoding/json" - "fmt" - "strconv" - "strings" - "time" - - "github.com/ClickHouse/clickhouse-go/v2/lib/driver" - "github.com/google/uuid" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" - eventsv1 "k8s.io/api/events/v1" - "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/watch" - "k8s.io/klog/v2" - - "go.miloapis.com/activity/internal/metrics" -) - -// ClickHouseEventsBackend implements the EventsBackend interface using ClickHouse. -type ClickHouseEventsBackend struct { - conn driver.Conn - config ClickHouseEventsConfig - natsConn NATSConnection // Optional NATS connection for watch support - publisher *EventsPublisher // Optional NATS publisher for event ingestion -} - -// NATSConnection defines the interface for NATS operations needed by the events backend. -// This allows for easy mocking in tests. -type NATSConnection interface { - Publish(subject string, data []byte) error - Subscribe(subject string, cb func(*NATSMessage)) (NATSSubscription, error) -} - -// NATSMessage represents a NATS message. -type NATSMessage struct { - Subject string - Data []byte -} - -// NATSSubscription represents a NATS subscription. -type NATSSubscription interface { - Unsubscribe() error -} - -// ClickHouseEventsConfig configures the ClickHouse events storage. -type ClickHouseEventsConfig struct { - Database string -} - -// NewClickHouseEventsBackend creates a new ClickHouse-backed events storage. -func NewClickHouseEventsBackend(conn driver.Conn, config ClickHouseEventsConfig) *ClickHouseEventsBackend { - return &ClickHouseEventsBackend{ - conn: conn, - config: config, - } -} - -// SetPublisher sets the NATS publisher for publishing events to the data pipeline. -// When a publisher is set, Create/Update operations will publish to NATS instead of -// writing directly to ClickHouse. Vector will consume from NATS and write to ClickHouse. -func (b *ClickHouseEventsBackend) SetPublisher(publisher *EventsPublisher) { - b.publisher = publisher -} - -// Create stores a new event in ClickHouse. -func (b *ClickHouseEventsBackend) Create(ctx context.Context, event *eventsv1.Event, scope ScopeContext) (*eventsv1.Event, error) { - ctx, span := tracer.Start(ctx, "clickhouse.events.create", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "INSERT"), - attribute.String("event.namespace", event.Namespace), - attribute.String("event.name", event.Name), - ), - ) - defer span.End() - - // Generate UID if not set - if event.UID == "" { - event.UID = types.UID(uuid.New().String()) - } - - // Set timestamps if not set - now := metav1.Now() - nowMicro := metav1.NewMicroTime(now.Time) - if event.EventTime.Time.IsZero() { - event.EventTime = nowMicro - } - // Initialize deprecated timestamps for compatibility - if event.DeprecatedFirstTimestamp.IsZero() { - event.DeprecatedFirstTimestamp = now - } - if event.DeprecatedLastTimestamp.IsZero() { - event.DeprecatedLastTimestamp = now - } - if event.DeprecatedCount == 0 { - event.DeprecatedCount = 1 - } - - // Set scope annotations - if event.Annotations == nil { - event.Annotations = make(map[string]string) - } - if scope.Type != "" && scope.Type != "platform" { - event.Annotations["platform.miloapis.com/scope.type"] = scope.Type - event.Annotations["platform.miloapis.com/scope.name"] = scope.Name - } - - // If NATS publisher is configured, publish to NATS instead of writing to ClickHouse - // Vector will consume from NATS and write to ClickHouse - if b.publisher != nil { - if err := b.publisher.Publish(ctx, event); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to publish event to NATS") - return nil, fmt.Errorf("failed to publish event to NATS: %w", err) - } - - // Set ResourceVersion to a temporary value - // The actual ResourceVersion will be set by Vector when it writes to ClickHouse - insertTime := time.Now() - event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) - - span.SetStatus(codes.Ok, "event published to NATS") - klog.V(4).InfoS("Published event to NATS", - "namespace", event.Namespace, - "name", event.Name, - "uid", event.UID, - "resourceVersion", event.ResourceVersion, - ) - - return event, nil - } - - // Fallback: Write directly to ClickHouse if no publisher configured - // Serialize event to JSON - eventJSON, err := json.Marshal(event) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to marshal event") - return nil, fmt.Errorf("failed to marshal event: %w", err) - } - - // Insert into ClickHouse - insertTime := time.Now() - query := fmt.Sprintf("INSERT INTO %s.%s (event_json, inserted_at) VALUES (?, ?)", - b.config.Database, "k8s_events") - - if err := b.conn.Exec(ctx, query, string(eventJSON), insertTime); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("insert").Inc() - span.RecordError(err) - span.SetStatus(codes.Error, "insert failed") - klog.ErrorS(err, "Failed to insert event", - "namespace", event.Namespace, - "name", event.Name, - ) - return nil, fmt.Errorf("failed to insert event: %w", err) - } - - // Set ResourceVersion from insertion time (nanoseconds for uniqueness) - event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) - - span.SetStatus(codes.Ok, "event created") - klog.V(4).InfoS("Created event", - "namespace", event.Namespace, - "name", event.Name, - "uid", event.UID, - "resourceVersion", event.ResourceVersion, - ) - - return event, nil -} - -// Get retrieves a single event by namespace and name. -func (b *ClickHouseEventsBackend) Get(ctx context.Context, namespace, name string, scope ScopeContext) (*eventsv1.Event, error) { - ctx, span := tracer.Start(ctx, "clickhouse.events.get", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.String("event.namespace", namespace), - attribute.String("event.name", name), - ), - ) - defer span.End() - - var conditions []string - var args []interface{} - - conditions = append(conditions, "namespace = ?", "name = ?") - args = append(args, namespace, name) - - // Add scope filtering - scopeConds, scopeArgs := b.buildScopeConditions(scope) - conditions = append(conditions, scopeConds...) - args = append(args, scopeArgs...) - - query := fmt.Sprintf( - "SELECT event_json, inserted_at FROM %s.%s WHERE %s ORDER BY inserted_at DESC LIMIT 1", - b.config.Database, "k8s_events", strings.Join(conditions, " AND ")) - - row := b.conn.QueryRow(ctx, query, args...) - - var eventJSON string - var insertedAt time.Time - if err := row.Scan(&eventJSON, &insertedAt); err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, errors.NewNotFound(eventsv1.Resource("events"), name) - } - span.RecordError(err) - span.SetStatus(codes.Error, "query failed") - return nil, fmt.Errorf("failed to get event: %w", err) - } - - var event eventsv1.Event - if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "unmarshal failed") - return nil, fmt.Errorf("failed to unmarshal event: %w", err) - } - - // Set ResourceVersion from insertion timestamp - event.ResourceVersion = strconv.FormatInt(insertedAt.UnixNano(), 10) - - span.SetStatus(codes.Ok, "event retrieved") - return &event, nil -} - -// List retrieves events matching the given namespace and options. -func (b *ClickHouseEventsBackend) List(ctx context.Context, namespace string, opts metav1.ListOptions, scope ScopeContext) (*eventsv1.EventList, error) { - ctx, span := tracer.Start(ctx, "clickhouse.events.list", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.String("event.namespace", namespace), - attribute.String("list.fieldSelector", opts.FieldSelector), - attribute.Int64("list.limit", opts.Limit), - ), - ) - defer span.End() - - var conditions []string - var args []interface{} - - // Enforce a 24-hour lookback window for native List calls. - // Use EventQuery for longer historical queries (up to 60 days). - window24h := time.Now().Add(-24 * time.Hour) - conditions = append([]string{"last_timestamp >= ?"}, conditions...) - args = append([]interface{}{window24h}, args...) - - // Namespace filter (if specified) - if namespace != "" { - conditions = append(conditions, "namespace = ?") - args = append(args, namespace) - } - - // Add scope filtering - scopeConds, scopeArgs := b.buildScopeConditions(scope) - conditions = append(conditions, scopeConds...) - args = append(args, scopeArgs...) - - // Parse and apply field selectors - if opts.FieldSelector != "" { - terms, err := ParseFieldSelector(opts.FieldSelector) - if err != nil { - return nil, errors.NewBadRequest(fmt.Sprintf("invalid field selector: %s", err)) - } - fieldConds, fieldArgs := FieldSelectorTermsToSQL(terms) - conditions = append(conditions, fieldConds...) - args = append(args, fieldArgs...) - } - - // Continue token for pagination - // The continue token is the ResourceVersion (inserted_at in nanoseconds) of the last item from the previous page - if opts.Continue != "" { - rv, err := strconv.ParseInt(opts.Continue, 10, 64) - if err == nil { - // For pagination, get events with inserted_at less than the continue token - // We order by inserted_at DESC for consistent pagination - rvTime := time.Unix(0, rv) - conditions = append(conditions, "inserted_at < ?") - args = append(args, rvTime) - } - } - - // Build WHERE clause - whereClause := "" - if len(conditions) > 0 { - whereClause = "WHERE " + strings.Join(conditions, " AND ") - } - - // Determine limit - limit := int64(500) // default - if opts.Limit > 0 { - limit = opts.Limit - } - - // Order by inserted_at DESC for consistent pagination with continue token - // inserted_at is the ResourceVersion, so this maintains chronological order - query := fmt.Sprintf( - "SELECT event_json, inserted_at FROM %s.%s %s ORDER BY inserted_at DESC LIMIT %d", - b.config.Database, "k8s_events", whereClause, limit+1) - - klog.V(4).InfoS("Executing events list query", - "query", query, - "args", args, - ) - - rows, err := b.conn.Query(ctx, query, args...) - if err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("query").Inc() - span.RecordError(err) - span.SetStatus(codes.Error, "query failed") - return nil, fmt.Errorf("failed to list events: %w", err) - } - defer rows.Close() - - var events []eventsv1.Event - var lastRV string - for rows.Next() { - var eventJSON string - var insertedAt time.Time - if err := rows.Scan(&eventJSON, &insertedAt); err != nil { - span.RecordError(err) - return nil, fmt.Errorf("failed to scan event: %w", err) - } - - var event eventsv1.Event - if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { - klog.ErrorS(err, "Failed to unmarshal event, skipping", "json", eventJSON[:min(len(eventJSON), 200)]) - continue - } - - event.ResourceVersion = strconv.FormatInt(insertedAt.UnixNano(), 10) - lastRV = event.ResourceVersion - events = append(events, event) - } - - if err := rows.Err(); err != nil { - span.RecordError(err) - return nil, fmt.Errorf("error iterating events: %w", err) - } - - // Check for more results - var continueToken string - if int64(len(events)) > limit { - events = events[:limit] - if len(events) > 0 { - continueToken = events[len(events)-1].ResourceVersion - } - } - - result := &eventsv1.EventList{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "events.k8s.io/v1", - Kind: "EventList", - }, - ListMeta: metav1.ListMeta{ - ResourceVersion: lastRV, - Continue: continueToken, - }, - Items: events, - } - - span.SetAttributes(attribute.Int("events.count", len(events))) - span.SetStatus(codes.Ok, "events listed") - return result, nil -} - -// Update modifies an existing event. -func (b *ClickHouseEventsBackend) Update(ctx context.Context, event *eventsv1.Event, scope ScopeContext) (*eventsv1.Event, error) { - ctx, span := tracer.Start(ctx, "clickhouse.events.update", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "INSERT"), - attribute.String("event.namespace", event.Namespace), - attribute.String("event.name", event.Name), - ), - ) - defer span.End() - - // Get existing event to verify it exists and check scope - existing, err := b.Get(ctx, event.Namespace, event.Name, scope) - if err != nil { - return nil, err - } - - // Preserve UID from existing event - if event.UID == "" { - event.UID = existing.UID - } - - // Update timestamps - now := metav1.Now() - nowMicro := metav1.NewMicroTime(now.Time) - event.EventTime = nowMicro - event.DeprecatedLastTimestamp = now - - // Update series for event aggregation - if event.Series == nil { - event.Series = &eventsv1.EventSeries{ - Count: 1, - LastObservedTime: nowMicro, - } - } - event.Series.Count = existing.Series.Count + 1 - event.Series.LastObservedTime = nowMicro - event.DeprecatedCount = event.Series.Count - - // Preserve firstTimestamp from original - if event.DeprecatedFirstTimestamp.IsZero() { - event.DeprecatedFirstTimestamp = existing.DeprecatedFirstTimestamp - } - - // Set scope annotations - if event.Annotations == nil { - event.Annotations = make(map[string]string) - } - if scope.Type != "" && scope.Type != "platform" { - event.Annotations["platform.miloapis.com/scope.type"] = scope.Type - event.Annotations["platform.miloapis.com/scope.name"] = scope.Name - } - - // If NATS publisher is configured, publish to NATS instead of writing to ClickHouse - // Vector will consume from NATS and write to ClickHouse - if b.publisher != nil { - if err := b.publisher.Publish(ctx, event); err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to publish event update to NATS") - return nil, fmt.Errorf("failed to publish event update to NATS: %w", err) - } - - // Set ResourceVersion to a temporary value - // The actual ResourceVersion will be set by Vector when it writes to ClickHouse - insertTime := time.Now() - event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) - - span.SetStatus(codes.Ok, "event update published to NATS") - klog.V(4).InfoS("Published event update to NATS", - "namespace", event.Namespace, - "name", event.Name, - "uid", event.UID, - "count", event.Series.Count, - "resourceVersion", event.ResourceVersion, - ) - - return event, nil - } - - // Fallback: Write directly to ClickHouse if no publisher configured - // Serialize event to JSON - eventJSON, err := json.Marshal(event) - if err != nil { - span.RecordError(err) - span.SetStatus(codes.Error, "failed to marshal event") - return nil, fmt.Errorf("failed to marshal event: %w", err) - } - - // Insert new version (ReplacingMergeTree will deduplicate by namespace, name, uid) - insertTime := time.Now() - query := fmt.Sprintf("INSERT INTO %s.%s (event_json, inserted_at) VALUES (?, ?)", - b.config.Database, "k8s_events") - - if err := b.conn.Exec(ctx, query, string(eventJSON), insertTime); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("insert").Inc() - span.RecordError(err) - span.SetStatus(codes.Error, "update failed") - return nil, fmt.Errorf("failed to update event: %w", err) - } - - // Set ResourceVersion from insertion time - event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) - - span.SetStatus(codes.Ok, "event updated") - klog.V(4).InfoS("Updated event", - "namespace", event.Namespace, - "name", event.Name, - "uid", event.UID, - "count", event.Series.Count, - "resourceVersion", event.ResourceVersion, - ) - - return event, nil -} - -// Delete removes an event by namespace and name. -// Note: In ClickHouse, we use a lightweight delete which marks rows for deletion -// during the next merge. For immediate consistency, we rely on the Get/List -// operations to filter out deleted events. -func (b *ClickHouseEventsBackend) Delete(ctx context.Context, namespace, name string, scope ScopeContext) error { - ctx, span := tracer.Start(ctx, "clickhouse.events.delete", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "DELETE"), - attribute.String("event.namespace", namespace), - attribute.String("event.name", name), - ), - ) - defer span.End() - - // Verify event exists and is within scope - _, err := b.Get(ctx, namespace, name, scope) - if err != nil { - if errors.IsNotFound(err) { - // Already deleted - success - span.SetStatus(codes.Ok, "event already deleted") - return nil - } - return err - } - - var conditions []string - var args []interface{} - - conditions = append(conditions, "namespace = ?", "name = ?") - args = append(args, namespace, name) - - // Add scope filtering - scopeConds, scopeArgs := b.buildScopeConditions(scope) - conditions = append(conditions, scopeConds...) - args = append(args, scopeArgs...) - - // Use lightweight delete (ALTER TABLE ... DELETE) - query := fmt.Sprintf( - "ALTER TABLE %s.%s DELETE WHERE %s", - b.config.Database, "k8s_events", strings.Join(conditions, " AND ")) - - if err := b.conn.Exec(ctx, query, args...); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("delete").Inc() - span.RecordError(err) - span.SetStatus(codes.Error, "delete failed") - return fmt.Errorf("failed to delete event: %w", err) - } - - span.SetStatus(codes.Ok, "event deleted") - klog.V(4).InfoS("Deleted event", - "namespace", namespace, - "name", name, - ) - - return nil -} - -// Watch returns a watch.Interface that streams event changes. -// Watch is implemented via NATS JetStream at the REST layer (EventsREST.Watch), -// not through the ClickHouse backend. This method exists only to satisfy the -// EventsBackend interface but should not be called directly. -// -// The REST layer uses EventsNATSWatcher (internal/watch/events_watcher.go) for -// real-time event streaming via NATS JetStream subscriptions. -func (b *ClickHouseEventsBackend) Watch(ctx context.Context, namespace string, opts metav1.ListOptions, scope ScopeContext) (watch.Interface, error) { - return nil, errors.NewMethodNotSupported(eventsv1.Resource("events"), "watch") -} - -// buildScopeConditions creates SQL conditions for scope filtering. -func (b *ClickHouseEventsBackend) buildScopeConditions(scope ScopeContext) ([]string, []interface{}) { - var conditions []string - var args []interface{} - - if scope.Type == "" || scope.Type == "platform" { - // Platform scope sees all events - return conditions, args - } - - switch scope.Type { - case "user": - // User scope: filter by user UID (not implemented for events) - // Events don't have a user field in the same way as audit logs - // For now, fall through to organization/project filtering - fallthrough - case "organization", "project": - conditions = append(conditions, "scope_type = ?", "scope_name = ?") - args = append(args, scope.Type, scope.Name) - } - - return conditions, args -} - +package storage + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2/lib/driver" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + eventsv1 "k8s.io/api/events/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/klog/v2" + + "go.miloapis.com/activity/internal/metrics" +) + +// ClickHouseEventsBackend implements the EventsBackend interface using ClickHouse. +type ClickHouseEventsBackend struct { + conn driver.Conn + config ClickHouseEventsConfig + natsConn NATSConnection // Optional NATS connection for watch support + publisher *EventsPublisher // Optional NATS publisher for event ingestion +} + +// NATSConnection defines the interface for NATS operations needed by the events backend. +// This allows for easy mocking in tests. +type NATSConnection interface { + Publish(subject string, data []byte) error + Subscribe(subject string, cb func(*NATSMessage)) (NATSSubscription, error) +} + +// NATSMessage represents a NATS message. +type NATSMessage struct { + Subject string + Data []byte +} + +// NATSSubscription represents a NATS subscription. +type NATSSubscription interface { + Unsubscribe() error +} + +// ClickHouseEventsConfig configures the ClickHouse events storage. +type ClickHouseEventsConfig struct { + Database string +} + +// NewClickHouseEventsBackend creates a new ClickHouse-backed events storage. +func NewClickHouseEventsBackend(conn driver.Conn, config ClickHouseEventsConfig) *ClickHouseEventsBackend { + return &ClickHouseEventsBackend{ + conn: conn, + config: config, + } +} + +// SetPublisher sets the NATS publisher for publishing events to the data pipeline. +// When a publisher is set, Create/Update operations will publish to NATS instead of +// writing directly to ClickHouse. Vector will consume from NATS and write to ClickHouse. +func (b *ClickHouseEventsBackend) SetPublisher(publisher *EventsPublisher) { + b.publisher = publisher +} + +// Create stores a new event in ClickHouse. +func (b *ClickHouseEventsBackend) Create(ctx context.Context, event *eventsv1.Event, scope ScopeContext) (*eventsv1.Event, error) { + ctx, span := tracer.Start(ctx, "clickhouse.events.create", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "INSERT"), + attribute.String("event.namespace", event.Namespace), + attribute.String("event.name", event.Name), + ), + ) + defer span.End() + + // Generate UID if not set + if event.UID == "" { + event.UID = types.UID(uuid.New().String()) + } + + // Set timestamps if not set + now := metav1.Now() + nowMicro := metav1.NewMicroTime(now.Time) + if event.EventTime.Time.IsZero() { + event.EventTime = nowMicro + } + // Initialize deprecated timestamps for compatibility + if event.DeprecatedFirstTimestamp.IsZero() { + event.DeprecatedFirstTimestamp = now + } + if event.DeprecatedLastTimestamp.IsZero() { + event.DeprecatedLastTimestamp = now + } + if event.DeprecatedCount == 0 { + event.DeprecatedCount = 1 + } + + // Set scope annotations + if event.Annotations == nil { + event.Annotations = make(map[string]string) + } + if scope.Type != "" && scope.Type != "platform" { + event.Annotations["platform.miloapis.com/scope.type"] = scope.Type + event.Annotations["platform.miloapis.com/scope.name"] = scope.Name + } + + // If NATS publisher is configured, publish to NATS instead of writing to ClickHouse + // Vector will consume from NATS and write to ClickHouse + if b.publisher != nil { + if err := b.publisher.Publish(ctx, event); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to publish event to NATS") + return nil, fmt.Errorf("failed to publish event to NATS: %w", err) + } + + // Set ResourceVersion to a temporary value + // The actual ResourceVersion will be set by Vector when it writes to ClickHouse + insertTime := time.Now() + event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) + + span.SetStatus(codes.Ok, "event published to NATS") + klog.V(4).InfoS("Published event to NATS", + "namespace", event.Namespace, + "name", event.Name, + "uid", event.UID, + "resourceVersion", event.ResourceVersion, + ) + + return event, nil + } + + // Fallback: Write directly to ClickHouse if no publisher configured + // Serialize event to JSON + eventJSON, err := json.Marshal(event) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to marshal event") + return nil, fmt.Errorf("failed to marshal event: %w", err) + } + + // Insert into ClickHouse + insertTime := time.Now() + query := fmt.Sprintf("INSERT INTO %s.%s (event_json, inserted_at) VALUES (?, ?)", + b.config.Database, "k8s_events") + + if err := b.conn.Exec(ctx, query, string(eventJSON), insertTime); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("insert").Inc() + span.RecordError(err) + span.SetStatus(codes.Error, "insert failed") + klog.ErrorS(err, "Failed to insert event", + "namespace", event.Namespace, + "name", event.Name, + ) + return nil, fmt.Errorf("failed to insert event: %w", err) + } + + // Set ResourceVersion from insertion time (nanoseconds for uniqueness) + event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) + + span.SetStatus(codes.Ok, "event created") + klog.V(4).InfoS("Created event", + "namespace", event.Namespace, + "name", event.Name, + "uid", event.UID, + "resourceVersion", event.ResourceVersion, + ) + + return event, nil +} + +// Get retrieves a single event by namespace and name. +func (b *ClickHouseEventsBackend) Get(ctx context.Context, namespace, name string, scope ScopeContext) (*eventsv1.Event, error) { + ctx, span := tracer.Start(ctx, "clickhouse.events.get", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.String("event.namespace", namespace), + attribute.String("event.name", name), + ), + ) + defer span.End() + + var conditions []string + var args []interface{} + + conditions = append(conditions, "namespace = ?", "name = ?") + args = append(args, namespace, name) + + // Add scope filtering + scopeConds, scopeArgs := b.buildScopeConditions(scope) + conditions = append(conditions, scopeConds...) + args = append(args, scopeArgs...) + + query := fmt.Sprintf( + "SELECT event_json, inserted_at FROM %s.%s WHERE %s ORDER BY inserted_at DESC LIMIT 1", + b.config.Database, "k8s_events", strings.Join(conditions, " AND ")) + + row := b.conn.QueryRow(ctx, query, args...) + + var eventJSON string + var insertedAt time.Time + if err := row.Scan(&eventJSON, &insertedAt); err != nil { + if strings.Contains(err.Error(), "no rows") { + return nil, errors.NewNotFound(eventsv1.Resource("events"), name) + } + span.RecordError(err) + span.SetStatus(codes.Error, "query failed") + return nil, fmt.Errorf("failed to get event: %w", err) + } + + var event eventsv1.Event + if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "unmarshal failed") + return nil, fmt.Errorf("failed to unmarshal event: %w", err) + } + + // Set ResourceVersion from insertion timestamp + event.ResourceVersion = strconv.FormatInt(insertedAt.UnixNano(), 10) + + span.SetStatus(codes.Ok, "event retrieved") + return &event, nil +} + +// List retrieves events matching the given namespace and options. +func (b *ClickHouseEventsBackend) List(ctx context.Context, namespace string, opts metav1.ListOptions, scope ScopeContext) (*eventsv1.EventList, error) { + ctx, span := tracer.Start(ctx, "clickhouse.events.list", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.String("event.namespace", namespace), + attribute.String("list.fieldSelector", opts.FieldSelector), + attribute.Int64("list.limit", opts.Limit), + ), + ) + defer span.End() + + var conditions []string + var args []interface{} + + // Enforce a 24-hour lookback window for native List calls. + // Use EventQuery for longer historical queries (up to 60 days). + window24h := time.Now().Add(-24 * time.Hour) + conditions = append([]string{"last_timestamp >= ?"}, conditions...) + args = append([]interface{}{window24h}, args...) + + // Namespace filter (if specified) + if namespace != "" { + conditions = append(conditions, "namespace = ?") + args = append(args, namespace) + } + + // Add scope filtering + scopeConds, scopeArgs := b.buildScopeConditions(scope) + conditions = append(conditions, scopeConds...) + args = append(args, scopeArgs...) + + // Parse and apply field selectors + if opts.FieldSelector != "" { + terms, err := ParseFieldSelector(opts.FieldSelector) + if err != nil { + return nil, errors.NewBadRequest(fmt.Sprintf("invalid field selector: %s", err)) + } + fieldConds, fieldArgs := FieldSelectorTermsToSQL(terms) + conditions = append(conditions, fieldConds...) + args = append(args, fieldArgs...) + } + + // Continue token for pagination + // The continue token is the ResourceVersion (inserted_at in nanoseconds) of the last item from the previous page + if opts.Continue != "" { + rv, err := strconv.ParseInt(opts.Continue, 10, 64) + if err == nil { + // For pagination, get events with inserted_at less than the continue token + // We order by inserted_at DESC for consistent pagination + rvTime := time.Unix(0, rv) + conditions = append(conditions, "inserted_at < ?") + args = append(args, rvTime) + } + } + + // Build WHERE clause + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + // Determine limit + limit := int64(500) // default + if opts.Limit > 0 { + limit = opts.Limit + } + + // Order by inserted_at DESC for consistent pagination with continue token + // inserted_at is the ResourceVersion, so this maintains chronological order + query := fmt.Sprintf( + "SELECT event_json, inserted_at FROM %s.%s %s ORDER BY inserted_at DESC LIMIT %d", + b.config.Database, "k8s_events", whereClause, limit+1) + + klog.V(4).InfoS("Executing events list query", + "query", query, + "args", args, + ) + + rows, err := b.conn.Query(ctx, query, args...) + if err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("query").Inc() + span.RecordError(err) + span.SetStatus(codes.Error, "query failed") + return nil, fmt.Errorf("failed to list events: %w", err) + } + defer rows.Close() + + var events []eventsv1.Event + var lastRV string + for rows.Next() { + var eventJSON string + var insertedAt time.Time + if err := rows.Scan(&eventJSON, &insertedAt); err != nil { + span.RecordError(err) + return nil, fmt.Errorf("failed to scan event: %w", err) + } + + var event eventsv1.Event + if err := json.Unmarshal([]byte(eventJSON), &event); err != nil { + klog.ErrorS(err, "Failed to unmarshal event, skipping", "json", eventJSON[:min(len(eventJSON), 200)]) + continue + } + + event.ResourceVersion = strconv.FormatInt(insertedAt.UnixNano(), 10) + lastRV = event.ResourceVersion + events = append(events, event) + } + + if err := rows.Err(); err != nil { + span.RecordError(err) + return nil, fmt.Errorf("error iterating events: %w", err) + } + + // Check for more results + var continueToken string + if int64(len(events)) > limit { + events = events[:limit] + if len(events) > 0 { + continueToken = events[len(events)-1].ResourceVersion + } + } + + result := &eventsv1.EventList{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "events.k8s.io/v1", + Kind: "EventList", + }, + ListMeta: metav1.ListMeta{ + ResourceVersion: lastRV, + Continue: continueToken, + }, + Items: events, + } + + span.SetAttributes(attribute.Int("events.count", len(events))) + span.SetStatus(codes.Ok, "events listed") + return result, nil +} + +// Update modifies an existing event. +func (b *ClickHouseEventsBackend) Update(ctx context.Context, event *eventsv1.Event, scope ScopeContext) (*eventsv1.Event, error) { + ctx, span := tracer.Start(ctx, "clickhouse.events.update", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "INSERT"), + attribute.String("event.namespace", event.Namespace), + attribute.String("event.name", event.Name), + ), + ) + defer span.End() + + // Get existing event to verify it exists and check scope + existing, err := b.Get(ctx, event.Namespace, event.Name, scope) + if err != nil { + return nil, err + } + + // Preserve UID from existing event + if event.UID == "" { + event.UID = existing.UID + } + + // Update timestamps + now := metav1.Now() + nowMicro := metav1.NewMicroTime(now.Time) + event.EventTime = nowMicro + event.DeprecatedLastTimestamp = now + + // Update series for event aggregation + if event.Series == nil { + event.Series = &eventsv1.EventSeries{ + Count: 1, + LastObservedTime: nowMicro, + } + } + event.Series.Count = existing.Series.Count + 1 + event.Series.LastObservedTime = nowMicro + event.DeprecatedCount = event.Series.Count + + // Preserve firstTimestamp from original + if event.DeprecatedFirstTimestamp.IsZero() { + event.DeprecatedFirstTimestamp = existing.DeprecatedFirstTimestamp + } + + // Set scope annotations + if event.Annotations == nil { + event.Annotations = make(map[string]string) + } + if scope.Type != "" && scope.Type != "platform" { + event.Annotations["platform.miloapis.com/scope.type"] = scope.Type + event.Annotations["platform.miloapis.com/scope.name"] = scope.Name + } + + // If NATS publisher is configured, publish to NATS instead of writing to ClickHouse + // Vector will consume from NATS and write to ClickHouse + if b.publisher != nil { + if err := b.publisher.Publish(ctx, event); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to publish event update to NATS") + return nil, fmt.Errorf("failed to publish event update to NATS: %w", err) + } + + // Set ResourceVersion to a temporary value + // The actual ResourceVersion will be set by Vector when it writes to ClickHouse + insertTime := time.Now() + event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) + + span.SetStatus(codes.Ok, "event update published to NATS") + klog.V(4).InfoS("Published event update to NATS", + "namespace", event.Namespace, + "name", event.Name, + "uid", event.UID, + "count", event.Series.Count, + "resourceVersion", event.ResourceVersion, + ) + + return event, nil + } + + // Fallback: Write directly to ClickHouse if no publisher configured + // Serialize event to JSON + eventJSON, err := json.Marshal(event) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "failed to marshal event") + return nil, fmt.Errorf("failed to marshal event: %w", err) + } + + // Insert new version (ReplacingMergeTree will deduplicate by namespace, name, uid) + insertTime := time.Now() + query := fmt.Sprintf("INSERT INTO %s.%s (event_json, inserted_at) VALUES (?, ?)", + b.config.Database, "k8s_events") + + if err := b.conn.Exec(ctx, query, string(eventJSON), insertTime); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("insert").Inc() + span.RecordError(err) + span.SetStatus(codes.Error, "update failed") + return nil, fmt.Errorf("failed to update event: %w", err) + } + + // Set ResourceVersion from insertion time + event.ResourceVersion = strconv.FormatInt(insertTime.UnixNano(), 10) + + span.SetStatus(codes.Ok, "event updated") + klog.V(4).InfoS("Updated event", + "namespace", event.Namespace, + "name", event.Name, + "uid", event.UID, + "count", event.Series.Count, + "resourceVersion", event.ResourceVersion, + ) + + return event, nil +} + +// Delete removes an event by namespace and name. +// Note: In ClickHouse, we use a lightweight delete which marks rows for deletion +// during the next merge. For immediate consistency, we rely on the Get/List +// operations to filter out deleted events. +func (b *ClickHouseEventsBackend) Delete(ctx context.Context, namespace, name string, scope ScopeContext) error { + ctx, span := tracer.Start(ctx, "clickhouse.events.delete", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "DELETE"), + attribute.String("event.namespace", namespace), + attribute.String("event.name", name), + ), + ) + defer span.End() + + // Verify event exists and is within scope + _, err := b.Get(ctx, namespace, name, scope) + if err != nil { + if errors.IsNotFound(err) { + // Already deleted - success + span.SetStatus(codes.Ok, "event already deleted") + return nil + } + return err + } + + var conditions []string + var args []interface{} + + conditions = append(conditions, "namespace = ?", "name = ?") + args = append(args, namespace, name) + + // Add scope filtering + scopeConds, scopeArgs := b.buildScopeConditions(scope) + conditions = append(conditions, scopeConds...) + args = append(args, scopeArgs...) + + // Use lightweight delete (ALTER TABLE ... DELETE) + query := fmt.Sprintf( + "ALTER TABLE %s.%s DELETE WHERE %s", + b.config.Database, "k8s_events", strings.Join(conditions, " AND ")) + + if err := b.conn.Exec(ctx, query, args...); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("delete").Inc() + span.RecordError(err) + span.SetStatus(codes.Error, "delete failed") + return fmt.Errorf("failed to delete event: %w", err) + } + + span.SetStatus(codes.Ok, "event deleted") + klog.V(4).InfoS("Deleted event", + "namespace", namespace, + "name", name, + ) + + return nil +} + +// Watch returns a watch.Interface that streams event changes. +// Watch is implemented via NATS JetStream at the REST layer (EventsREST.Watch), +// not through the ClickHouse backend. This method exists only to satisfy the +// EventsBackend interface but should not be called directly. +// +// The REST layer uses EventsNATSWatcher (internal/watch/events_watcher.go) for +// real-time event streaming via NATS JetStream subscriptions. +func (b *ClickHouseEventsBackend) Watch(ctx context.Context, namespace string, opts metav1.ListOptions, scope ScopeContext) (watch.Interface, error) { + return nil, errors.NewMethodNotSupported(eventsv1.Resource("events"), "watch") +} + +// buildScopeConditions creates SQL conditions for scope filtering. +func (b *ClickHouseEventsBackend) buildScopeConditions(scope ScopeContext) ([]string, []interface{}) { + var conditions []string + var args []interface{} + + if scope.Type == "" || scope.Type == "platform" { + // Platform scope sees all events + return conditions, args + } + + switch scope.Type { + case "user": + // User scope: filter by user UID (not implemented for events) + // Events don't have a user field in the same way as audit logs + // For now, fall through to organization/project filtering + fallthrough + case "organization", "project": + conditions = append(conditions, "scope_type = ?", "scope_name = ?") + args = append(args, scope.Type, scope.Name) + } + + return conditions, args +} + diff --git a/internal/storage/events_facets.go b/internal/storage/events_facets.go index 8edf6a4b..2efa9277 100644 --- a/internal/storage/events_facets.go +++ b/internal/storage/events_facets.go @@ -1,162 +1,162 @@ -package storage - -import ( - "context" - "fmt" - "strings" - "time" - - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/codes" - "go.opentelemetry.io/otel/trace" - "k8s.io/klog/v2" - - "go.miloapis.com/activity/internal/metrics" - "go.miloapis.com/activity/internal/timeutil" -) - -// EventFacetQuerySpec defines the parameters for an event facet query. -type EventFacetQuerySpec struct { - // TimeRange specifies the time window for facet aggregation. - StartTime string - EndTime string - - // Facets are the fields to compute distinct values for. - Facets []FacetFieldSpec -} - -// QueryEventFacets retrieves distinct field values with counts for Kubernetes Event faceted search. -func (b *ClickHouseEventsBackend) QueryEventFacets(ctx context.Context, spec EventFacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { - ctx, span := tracer.Start(ctx, "clickhouse.query_event_facets", - trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - attribute.String("db.system", "clickhouse"), - attribute.String("db.name", b.config.Database), - attribute.String("db.operation", "SELECT"), - attribute.Int("facet.count", len(spec.Facets)), - ), - ) - defer span.End() - - result := &FacetQueryResult{ - Facets: make([]FacetFieldResult, 0, len(spec.Facets)), - } - - // Execute each facet query - for _, facet := range spec.Facets { - facetResult, err := b.queryEventFacet(ctx, facet, spec, scope) - if err != nil { - span.RecordError(err) - return nil, fmt.Errorf("failed to query event facet %s: %w", facet.Field, err) - } - result.Facets = append(result.Facets, *facetResult) - } - - span.SetStatus(codes.Ok, "event facet query successful") - return result, nil -} - -// queryEventFacet executes a single facet query against the events table. -func (b *ClickHouseEventsBackend) queryEventFacet(ctx context.Context, facet FacetFieldSpec, spec EventFacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { - column, err := GetEventFacetColumn(facet.Field) - if err != nil { - return nil, err - } - - limit := facet.Limit - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - - var args []interface{} - var conditions []string - - // Scope filtering - scopeConds, scopeArgs := b.buildScopeConditions(scope) - conditions = append(conditions, scopeConds...) - args = append(args, scopeArgs...) - - // Time range - now := time.Now() - if spec.StartTime != "" { - startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) - if err != nil { - return nil, fmt.Errorf("invalid startTime: %w", err) - } - conditions = append(conditions, "last_timestamp >= ?") - args = append(args, startTime) - } - - if spec.EndTime != "" { - endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) - if err != nil { - return nil, fmt.Errorf("invalid endTime: %w", err) - } - conditions = append(conditions, "last_timestamp < ?") - args = append(args, endTime) - } - - // Build query against the events table - query := fmt.Sprintf("SELECT %s, COUNT(*) as count FROM %s.%s", column, b.config.Database, "k8s_events") - - if len(conditions) > 0 { - query += " WHERE " + strings.Join(conditions, " AND ") - } - - // Group by the facet column and order by count descending, then value ascending for stability - query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, %s ASC LIMIT %d", column, column, limit) - - klog.V(4).InfoS("Executing event facet query", - "field", facet.Field, - "column", column, - "query", query, - ) - - rows, err := b.conn.Query(ctx, query, args...) - if err != nil { - // Classify error type - errorType := "unknown" - errStr := err.Error() - if strings.Contains(errStr, "connection") { - errorType = "connection" - } else if strings.Contains(errStr, "timeout") { - errorType = "timeout" - } else if strings.Contains(errStr, "syntax") { - errorType = "syntax" - } - metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() - klog.ErrorS(err, "Event facet query failed", "field", facet.Field, "errorType", errorType) - return nil, fmt.Errorf("failed to execute event facet query: %w", err) - } - defer rows.Close() - - result := &FacetFieldResult{ - Field: facet.Field, - Values: make([]FacetValueResult, 0), - } - - for rows.Next() { - var value string - var count uint64 - if err := rows.Scan(&value, &count); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("scan").Inc() - klog.ErrorS(err, "Failed to scan event facet row", "field", facet.Field) - return nil, fmt.Errorf("failed to scan event facet row: %w", err) - } - result.Values = append(result.Values, FacetValueResult{ - Value: value, - Count: int64(count), - }) - } - - if err := rows.Err(); err != nil { - metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() - klog.ErrorS(err, "Error iterating event facet rows", "field", facet.Field) - return nil, fmt.Errorf("error iterating event facet rows: %w", err) - } - - return result, nil -} +package storage + +import ( + "context" + "fmt" + "strings" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "k8s.io/klog/v2" + + "go.miloapis.com/activity/internal/metrics" + "go.miloapis.com/activity/internal/timeutil" +) + +// EventFacetQuerySpec defines the parameters for an event facet query. +type EventFacetQuerySpec struct { + // TimeRange specifies the time window for facet aggregation. + StartTime string + EndTime string + + // Facets are the fields to compute distinct values for. + Facets []FacetFieldSpec +} + +// QueryEventFacets retrieves distinct field values with counts for Kubernetes Event faceted search. +func (b *ClickHouseEventsBackend) QueryEventFacets(ctx context.Context, spec EventFacetQuerySpec, scope ScopeContext) (*FacetQueryResult, error) { + ctx, span := tracer.Start(ctx, "clickhouse.query_event_facets", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + attribute.String("db.system", "clickhouse"), + attribute.String("db.name", b.config.Database), + attribute.String("db.operation", "SELECT"), + attribute.Int("facet.count", len(spec.Facets)), + ), + ) + defer span.End() + + result := &FacetQueryResult{ + Facets: make([]FacetFieldResult, 0, len(spec.Facets)), + } + + // Execute each facet query + for _, facet := range spec.Facets { + facetResult, err := b.queryEventFacet(ctx, facet, spec, scope) + if err != nil { + span.RecordError(err) + return nil, fmt.Errorf("failed to query event facet %s: %w", facet.Field, err) + } + result.Facets = append(result.Facets, *facetResult) + } + + span.SetStatus(codes.Ok, "event facet query successful") + return result, nil +} + +// queryEventFacet executes a single facet query against the events table. +func (b *ClickHouseEventsBackend) queryEventFacet(ctx context.Context, facet FacetFieldSpec, spec EventFacetQuerySpec, scope ScopeContext) (*FacetFieldResult, error) { + column, err := GetEventFacetColumn(facet.Field) + if err != nil { + return nil, err + } + + limit := facet.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + var args []interface{} + var conditions []string + + // Scope filtering + scopeConds, scopeArgs := b.buildScopeConditions(scope) + conditions = append(conditions, scopeConds...) + args = append(args, scopeArgs...) + + // Time range + now := time.Now() + if spec.StartTime != "" { + startTime, err := timeutil.ParseFlexibleTime(spec.StartTime, now) + if err != nil { + return nil, fmt.Errorf("invalid startTime: %w", err) + } + conditions = append(conditions, "last_timestamp >= ?") + args = append(args, startTime) + } + + if spec.EndTime != "" { + endTime, err := timeutil.ParseFlexibleTime(spec.EndTime, now) + if err != nil { + return nil, fmt.Errorf("invalid endTime: %w", err) + } + conditions = append(conditions, "last_timestamp < ?") + args = append(args, endTime) + } + + // Build query against the events table + query := fmt.Sprintf("SELECT %s, COUNT(*) as count FROM %s.%s", column, b.config.Database, "k8s_events") + + if len(conditions) > 0 { + query += " WHERE " + strings.Join(conditions, " AND ") + } + + // Group by the facet column and order by count descending, then value ascending for stability + query += fmt.Sprintf(" GROUP BY %s ORDER BY count DESC, %s ASC LIMIT %d", column, column, limit) + + klog.V(4).InfoS("Executing event facet query", + "field", facet.Field, + "column", column, + "query", query, + ) + + rows, err := b.conn.Query(ctx, query, args...) + if err != nil { + // Classify error type + errorType := "unknown" + errStr := err.Error() + if strings.Contains(errStr, "connection") { + errorType = "connection" + } else if strings.Contains(errStr, "timeout") { + errorType = "timeout" + } else if strings.Contains(errStr, "syntax") { + errorType = "syntax" + } + metrics.ClickHouseQueryErrors.WithLabelValues(errorType).Inc() + klog.ErrorS(err, "Event facet query failed", "field", facet.Field, "errorType", errorType) + return nil, fmt.Errorf("failed to execute event facet query: %w", err) + } + defer rows.Close() + + result := &FacetFieldResult{ + Field: facet.Field, + Values: make([]FacetValueResult, 0), + } + + for rows.Next() { + var value string + var count uint64 + if err := rows.Scan(&value, &count); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("scan").Inc() + klog.ErrorS(err, "Failed to scan event facet row", "field", facet.Field) + return nil, fmt.Errorf("failed to scan event facet row: %w", err) + } + result.Values = append(result.Values, FacetValueResult{ + Value: value, + Count: int64(count), + }) + } + + if err := rows.Err(); err != nil { + metrics.ClickHouseQueryErrors.WithLabelValues("iteration").Inc() + klog.ErrorS(err, "Error iterating event facet rows", "field", facet.Field) + return nil, fmt.Errorf("error iterating event facet rows: %w", err) + } + + return result, nil +} diff --git a/internal/storage/events_fields.go b/internal/storage/events_fields.go index df31daa7..a1400dfe 100644 --- a/internal/storage/events_fields.go +++ b/internal/storage/events_fields.go @@ -1,163 +1,163 @@ -package storage - -import ( - "fmt" - "strings" -) - -// EventsFieldSelectors defines the supported field selectors for Kubernetes Events. -// Keys are Kubernetes field selector paths, values are ClickHouse column names. -// These map standard kubectl field selector syntax to our ClickHouse schema. -var EventsFieldSelectors = map[string]string{ - // Metadata fields - "metadata.namespace": "namespace", - "metadata.name": "name", - "metadata.uid": "uid", - - // Regarding object fields (events/v1) - "regarding.apiVersion": "regarding_api_version", - "regarding.kind": "regarding_kind", - "regarding.namespace": "regarding_namespace", - "regarding.name": "regarding_name", - "regarding.uid": "regarding_uid", - "regarding.fieldPath": "regarding_field_path", - - // Event classification - "reason": "reason", - "type": "type", - - // Source fields - "source.component": "source_component", - "source.host": "source_host", - - // Reporting fields (for newer Event API) - "reportingComponent": "source_component", - "reportingInstance": "source_host", -} - -// EventsFieldSelectorAliases maps common short forms to full paths. -// These allow users to use simpler syntax like "reason=Pulled" instead of full paths. -var EventsFieldSelectorAliases = map[string]string{ - "namespace": "metadata.namespace", - "name": "metadata.name", - "uid": "metadata.uid", -} - -// ResolveEventFieldSelector resolves a field selector key to a ClickHouse column name. -// It handles both full paths (regarding.name) and aliases (namespace). -// Returns an error if the field selector is not supported. -func ResolveEventFieldSelector(field string) (string, error) { - // Check for alias first - if fullPath, ok := EventsFieldSelectorAliases[field]; ok { - field = fullPath - } - - // Look up the ClickHouse column - column, ok := EventsFieldSelectors[field] - if !ok { - return "", fmt.Errorf("unsupported field selector: %s", field) - } - - return column, nil -} - -// ParseFieldSelector parses a Kubernetes field selector string and returns -// a list of (column, operator, value) tuples for building WHERE clauses. -// Supports both = (equality) and != (inequality) operators. -// Example: "regarding.name=my-pod,type!=Warning" -func ParseFieldSelector(selector string) ([]FieldSelectorTerm, error) { - if selector == "" { - return nil, nil - } - - var terms []FieldSelectorTerm - parts := strings.Split(selector, ",") - - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { - continue - } - - var field, value string - var op FieldSelectorOp - - // Check for != first (before =) - if idx := strings.Index(part, "!="); idx != -1 { - field = strings.TrimSpace(part[:idx]) - value = strings.TrimSpace(part[idx+2:]) - op = FieldSelectorNotEqual - } else if idx := strings.Index(part, "=="); idx != -1 { - // Support == as alias for = - field = strings.TrimSpace(part[:idx]) - value = strings.TrimSpace(part[idx+2:]) - op = FieldSelectorEqual - } else if idx := strings.Index(part, "="); idx != -1 { - field = strings.TrimSpace(part[:idx]) - value = strings.TrimSpace(part[idx+1:]) - op = FieldSelectorEqual - } else { - return nil, fmt.Errorf("invalid field selector syntax: %s (expected field=value or field!=value)", part) - } - - column, err := ResolveEventFieldSelector(field) - if err != nil { - supported := make([]string, 0, len(EventsFieldSelectors)) - for k := range EventsFieldSelectors { - supported = append(supported, k) - } - return nil, fmt.Errorf("%s. Supported fields: %s", err.Error(), strings.Join(supported, ", ")) - } - - terms = append(terms, FieldSelectorTerm{ - Column: column, - Operator: op, - Value: value, - }) - } - - return terms, nil -} - -// FieldSelectorOp represents a field selector operator. -type FieldSelectorOp string - -const ( - // FieldSelectorEqual represents the = operator. - FieldSelectorEqual FieldSelectorOp = "=" - // FieldSelectorNotEqual represents the != operator. - FieldSelectorNotEqual FieldSelectorOp = "!=" -) - -// FieldSelectorTerm represents a single field selector condition. -type FieldSelectorTerm struct { - Column string // ClickHouse column name - Operator FieldSelectorOp // = or != - Value string // The value to compare against -} - -// ToSQL converts a field selector term to a SQL condition and argument. -// Returns the SQL fragment (e.g., "namespace = ?") and the argument value. -func (t FieldSelectorTerm) ToSQL() (string, interface{}) { - switch t.Operator { - case FieldSelectorNotEqual: - return fmt.Sprintf("%s != ?", t.Column), t.Value - default: - return fmt.Sprintf("%s = ?", t.Column), t.Value - } -} - -// FieldSelectorTermsToSQL converts multiple field selector terms to SQL. -// Returns a slice of SQL conditions and corresponding arguments. -func FieldSelectorTermsToSQL(terms []FieldSelectorTerm) ([]string, []interface{}) { - conditions := make([]string, 0, len(terms)) - args := make([]interface{}, 0, len(terms)) - - for _, term := range terms { - sql, arg := term.ToSQL() - conditions = append(conditions, sql) - args = append(args, arg) - } - - return conditions, args -} +package storage + +import ( + "fmt" + "strings" +) + +// EventsFieldSelectors defines the supported field selectors for Kubernetes Events. +// Keys are Kubernetes field selector paths, values are ClickHouse column names. +// These map standard kubectl field selector syntax to our ClickHouse schema. +var EventsFieldSelectors = map[string]string{ + // Metadata fields + "metadata.namespace": "namespace", + "metadata.name": "name", + "metadata.uid": "uid", + + // Regarding object fields (events/v1) + "regarding.apiVersion": "regarding_api_version", + "regarding.kind": "regarding_kind", + "regarding.namespace": "regarding_namespace", + "regarding.name": "regarding_name", + "regarding.uid": "regarding_uid", + "regarding.fieldPath": "regarding_field_path", + + // Event classification + "reason": "reason", + "type": "type", + + // Source fields + "source.component": "source_component", + "source.host": "source_host", + + // Reporting fields (for newer Event API) + "reportingComponent": "source_component", + "reportingInstance": "source_host", +} + +// EventsFieldSelectorAliases maps common short forms to full paths. +// These allow users to use simpler syntax like "reason=Pulled" instead of full paths. +var EventsFieldSelectorAliases = map[string]string{ + "namespace": "metadata.namespace", + "name": "metadata.name", + "uid": "metadata.uid", +} + +// ResolveEventFieldSelector resolves a field selector key to a ClickHouse column name. +// It handles both full paths (regarding.name) and aliases (namespace). +// Returns an error if the field selector is not supported. +func ResolveEventFieldSelector(field string) (string, error) { + // Check for alias first + if fullPath, ok := EventsFieldSelectorAliases[field]; ok { + field = fullPath + } + + // Look up the ClickHouse column + column, ok := EventsFieldSelectors[field] + if !ok { + return "", fmt.Errorf("unsupported field selector: %s", field) + } + + return column, nil +} + +// ParseFieldSelector parses a Kubernetes field selector string and returns +// a list of (column, operator, value) tuples for building WHERE clauses. +// Supports both = (equality) and != (inequality) operators. +// Example: "regarding.name=my-pod,type!=Warning" +func ParseFieldSelector(selector string) ([]FieldSelectorTerm, error) { + if selector == "" { + return nil, nil + } + + var terms []FieldSelectorTerm + parts := strings.Split(selector, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + var field, value string + var op FieldSelectorOp + + // Check for != first (before =) + if idx := strings.Index(part, "!="); idx != -1 { + field = strings.TrimSpace(part[:idx]) + value = strings.TrimSpace(part[idx+2:]) + op = FieldSelectorNotEqual + } else if idx := strings.Index(part, "=="); idx != -1 { + // Support == as alias for = + field = strings.TrimSpace(part[:idx]) + value = strings.TrimSpace(part[idx+2:]) + op = FieldSelectorEqual + } else if idx := strings.Index(part, "="); idx != -1 { + field = strings.TrimSpace(part[:idx]) + value = strings.TrimSpace(part[idx+1:]) + op = FieldSelectorEqual + } else { + return nil, fmt.Errorf("invalid field selector syntax: %s (expected field=value or field!=value)", part) + } + + column, err := ResolveEventFieldSelector(field) + if err != nil { + supported := make([]string, 0, len(EventsFieldSelectors)) + for k := range EventsFieldSelectors { + supported = append(supported, k) + } + return nil, fmt.Errorf("%s. Supported fields: %s", err.Error(), strings.Join(supported, ", ")) + } + + terms = append(terms, FieldSelectorTerm{ + Column: column, + Operator: op, + Value: value, + }) + } + + return terms, nil +} + +// FieldSelectorOp represents a field selector operator. +type FieldSelectorOp string + +const ( + // FieldSelectorEqual represents the = operator. + FieldSelectorEqual FieldSelectorOp = "=" + // FieldSelectorNotEqual represents the != operator. + FieldSelectorNotEqual FieldSelectorOp = "!=" +) + +// FieldSelectorTerm represents a single field selector condition. +type FieldSelectorTerm struct { + Column string // ClickHouse column name + Operator FieldSelectorOp // = or != + Value string // The value to compare against +} + +// ToSQL converts a field selector term to a SQL condition and argument. +// Returns the SQL fragment (e.g., "namespace = ?") and the argument value. +func (t FieldSelectorTerm) ToSQL() (string, interface{}) { + switch t.Operator { + case FieldSelectorNotEqual: + return fmt.Sprintf("%s != ?", t.Column), t.Value + default: + return fmt.Sprintf("%s = ?", t.Column), t.Value + } +} + +// FieldSelectorTermsToSQL converts multiple field selector terms to SQL. +// Returns a slice of SQL conditions and corresponding arguments. +func FieldSelectorTermsToSQL(terms []FieldSelectorTerm) ([]string, []interface{}) { + conditions := make([]string, 0, len(terms)) + args := make([]interface{}, 0, len(terms)) + + for _, term := range terms { + sql, arg := term.ToSQL() + conditions = append(conditions, sql) + args = append(args, arg) + } + + return conditions, args +} diff --git a/internal/storage/events_fields_test.go b/internal/storage/events_fields_test.go index d5b40260..403029e2 100644 --- a/internal/storage/events_fields_test.go +++ b/internal/storage/events_fields_test.go @@ -1,140 +1,140 @@ -package storage - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestParseFieldSelector_RegardingFields(t *testing.T) { - tests := []struct { - name string - selector string - expectedColumn string - expectedOp FieldSelectorOp - expectedValue string - wantErr bool - }{ - { - name: "regarding.kind", - selector: "regarding.kind=Pod", - expectedColumn: "regarding_kind", - expectedOp: FieldSelectorEqual, - expectedValue: "Pod", - wantErr: false, - }, - { - name: "regarding.namespace", - selector: "regarding.namespace=default", - expectedColumn: "regarding_namespace", - expectedOp: FieldSelectorEqual, - expectedValue: "default", - wantErr: false, - }, - { - name: "regarding.name", - selector: "regarding.name=my-pod", - expectedColumn: "regarding_name", - expectedOp: FieldSelectorEqual, - expectedValue: "my-pod", - wantErr: false, - }, - { - name: "regarding.uid", - selector: "regarding.uid=123e4567-e89b-12d3-a456-426614174000", - expectedColumn: "regarding_uid", - expectedOp: FieldSelectorEqual, - expectedValue: "123e4567-e89b-12d3-a456-426614174000", - wantErr: false, - }, - { - name: "regarding.apiVersion", - selector: "regarding.apiVersion=apps/v1", - expectedColumn: "regarding_api_version", - expectedOp: FieldSelectorEqual, - expectedValue: "apps/v1", - wantErr: false, - }, - { - name: "regarding.fieldPath", - selector: "regarding.fieldPath=spec.containers{nginx}", - expectedColumn: "regarding_field_path", - expectedOp: FieldSelectorEqual, - expectedValue: "spec.containers{nginx}", - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - terms, err := ParseFieldSelector(tt.selector) - if tt.wantErr { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Len(t, terms, 1) - - assert.Equal(t, tt.expectedColumn, terms[0].Column) - assert.Equal(t, tt.expectedOp, terms[0].Operator) - assert.Equal(t, tt.expectedValue, terms[0].Value) - }) - } -} - - -func TestParseFieldSelector_RegardingWithMultipleFields(t *testing.T) { - selector := "regarding.kind=Pod,regarding.namespace=default,type=Warning" - terms, err := ParseFieldSelector(selector) - require.NoError(t, err) - require.Len(t, terms, 3) - - assert.Equal(t, "regarding_kind", terms[0].Column) - assert.Equal(t, "Pod", terms[0].Value) - - assert.Equal(t, "regarding_namespace", terms[1].Column) - assert.Equal(t, "default", terms[1].Value) - - assert.Equal(t, "type", terms[2].Column) - assert.Equal(t, "Warning", terms[2].Value) -} - -func TestParseFieldSelector_RegardingWithNotEqual(t *testing.T) { - selector := "regarding.kind!=ConfigMap" - terms, err := ParseFieldSelector(selector) - require.NoError(t, err) - require.Len(t, terms, 1) - - assert.Equal(t, "regarding_kind", terms[0].Column) - assert.Equal(t, FieldSelectorNotEqual, terms[0].Operator) - assert.Equal(t, "ConfigMap", terms[0].Value) -} - -func TestResolveEventFieldSelector_RegardingFields(t *testing.T) { - tests := []struct { - field string - expected string - }{ - {"regarding.kind", "regarding_kind"}, - {"regarding.namespace", "regarding_namespace"}, - {"regarding.name", "regarding_name"}, - {"regarding.uid", "regarding_uid"}, - {"regarding.apiVersion", "regarding_api_version"}, - {"regarding.fieldPath", "regarding_field_path"}, - } - - for _, tt := range tests { - t.Run(tt.field, func(t *testing.T) { - column, err := ResolveEventFieldSelector(tt.field) - require.NoError(t, err) - assert.Equal(t, tt.expected, column) - }) - } -} - -func TestResolveEventFieldSelector_UnsupportedRegardingField(t *testing.T) { - _, err := ResolveEventFieldSelector("regarding.unsupportedField") - require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported field selector") -} +package storage + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseFieldSelector_RegardingFields(t *testing.T) { + tests := []struct { + name string + selector string + expectedColumn string + expectedOp FieldSelectorOp + expectedValue string + wantErr bool + }{ + { + name: "regarding.kind", + selector: "regarding.kind=Pod", + expectedColumn: "regarding_kind", + expectedOp: FieldSelectorEqual, + expectedValue: "Pod", + wantErr: false, + }, + { + name: "regarding.namespace", + selector: "regarding.namespace=default", + expectedColumn: "regarding_namespace", + expectedOp: FieldSelectorEqual, + expectedValue: "default", + wantErr: false, + }, + { + name: "regarding.name", + selector: "regarding.name=my-pod", + expectedColumn: "regarding_name", + expectedOp: FieldSelectorEqual, + expectedValue: "my-pod", + wantErr: false, + }, + { + name: "regarding.uid", + selector: "regarding.uid=123e4567-e89b-12d3-a456-426614174000", + expectedColumn: "regarding_uid", + expectedOp: FieldSelectorEqual, + expectedValue: "123e4567-e89b-12d3-a456-426614174000", + wantErr: false, + }, + { + name: "regarding.apiVersion", + selector: "regarding.apiVersion=apps/v1", + expectedColumn: "regarding_api_version", + expectedOp: FieldSelectorEqual, + expectedValue: "apps/v1", + wantErr: false, + }, + { + name: "regarding.fieldPath", + selector: "regarding.fieldPath=spec.containers{nginx}", + expectedColumn: "regarding_field_path", + expectedOp: FieldSelectorEqual, + expectedValue: "spec.containers{nginx}", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + terms, err := ParseFieldSelector(tt.selector) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, terms, 1) + + assert.Equal(t, tt.expectedColumn, terms[0].Column) + assert.Equal(t, tt.expectedOp, terms[0].Operator) + assert.Equal(t, tt.expectedValue, terms[0].Value) + }) + } +} + + +func TestParseFieldSelector_RegardingWithMultipleFields(t *testing.T) { + selector := "regarding.kind=Pod,regarding.namespace=default,type=Warning" + terms, err := ParseFieldSelector(selector) + require.NoError(t, err) + require.Len(t, terms, 3) + + assert.Equal(t, "regarding_kind", terms[0].Column) + assert.Equal(t, "Pod", terms[0].Value) + + assert.Equal(t, "regarding_namespace", terms[1].Column) + assert.Equal(t, "default", terms[1].Value) + + assert.Equal(t, "type", terms[2].Column) + assert.Equal(t, "Warning", terms[2].Value) +} + +func TestParseFieldSelector_RegardingWithNotEqual(t *testing.T) { + selector := "regarding.kind!=ConfigMap" + terms, err := ParseFieldSelector(selector) + require.NoError(t, err) + require.Len(t, terms, 1) + + assert.Equal(t, "regarding_kind", terms[0].Column) + assert.Equal(t, FieldSelectorNotEqual, terms[0].Operator) + assert.Equal(t, "ConfigMap", terms[0].Value) +} + +func TestResolveEventFieldSelector_RegardingFields(t *testing.T) { + tests := []struct { + field string + expected string + }{ + {"regarding.kind", "regarding_kind"}, + {"regarding.namespace", "regarding_namespace"}, + {"regarding.name", "regarding_name"}, + {"regarding.uid", "regarding_uid"}, + {"regarding.apiVersion", "regarding_api_version"}, + {"regarding.fieldPath", "regarding_field_path"}, + } + + for _, tt := range tests { + t.Run(tt.field, func(t *testing.T) { + column, err := ResolveEventFieldSelector(tt.field) + require.NoError(t, err) + assert.Equal(t, tt.expected, column) + }) + } +} + +func TestResolveEventFieldSelector_UnsupportedRegardingField(t *testing.T) { + _, err := ResolveEventFieldSelector("regarding.unsupportedField") + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported field selector") +} diff --git a/internal/storage/fields.go b/internal/storage/fields.go index 5be7e40c..60b621b3 100644 --- a/internal/storage/fields.go +++ b/internal/storage/fields.go @@ -1,235 +1,235 @@ -package storage - -import ( - "fmt" - "sort" - "strings" - - corev1 "k8s.io/api/core/v1" -) - -// AuditLogFacetFields defines the supported fields for audit log facet queries. -// Keys are API field paths (as used in queries), values are human-readable descriptions. -var AuditLogFacetFields = map[string]string{ - "verb": "The API verb (get, list, create, update, delete, etc.)", - "user.username": "The username of the actor", - "user.uid": "The UID of the actor", - "responseStatus.code": "The HTTP response status code", - "objectRef.namespace": "The namespace of the target object", - "objectRef.resource": "The resource type", - "objectRef.apiGroup": "The API group of the target resource", -} - -// IsValidAuditLogFacetField checks if a field is supported for audit log faceting. -func IsValidAuditLogFacetField(field string) bool { - _, ok := AuditLogFacetFields[field] - return ok -} - -// AuditLogFacetFieldNames returns a sorted list of supported audit log facet field names. -func AuditLogFacetFieldNames() []string { - return sortedKeys(AuditLogFacetFields) -} - -// FormatSupportedFields returns a comma-separated string of supported field names for error messages. -func FormatSupportedFields(fields map[string]string) string { - names := sortedKeys(fields) - return strings.Join(names, ", ") -} - -// sortedKeys returns the keys of a map in sorted order. -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// auditLogFacetColumnMapping maps API field paths to ClickHouse column names for audit logs. -// This is internal to the storage layer - only the field names are exposed publicly. -var auditLogFacetColumnMapping = map[string]string{ - "verb": "verb", - "user.username": "user", - "user.uid": "user_uid", - "responseStatus.code": "status_code", - "objectRef.namespace": "namespace", - "objectRef.resource": "resource", - "objectRef.apiGroup": "api_group", -} - -// GetAuditLogFacetColumn returns the ClickHouse column name for an audit log facet field. -// Returns an error if the field is not supported. -func GetAuditLogFacetColumn(field string) (string, error) { - col, ok := auditLogFacetColumnMapping[field] - if !ok { - return "", fmt.Errorf("unsupported audit log facet field: %s", field) - } - return col, nil -} - -// ActivityFacetFields defines the supported fields for activity facet queries. -// Keys are API field paths (as used in queries), values are human-readable descriptions. -var ActivityFacetFields = map[string]string{ - "spec.actor.name": "The name of the actor who performed the action", - "spec.actor.type": "The type of actor (user, service, system)", - "spec.resource.apiGroup": "The API group of the target resource", - "spec.resource.kind": "The kind of the target resource", - "spec.resource.namespace": "The namespace of the target resource", - "spec.changeSource": "The source of the change (human, automation, system)", -} - -// IsValidActivityFacetField checks if a field is supported for activity faceting. -func IsValidActivityFacetField(field string) bool { - _, ok := ActivityFacetFields[field] - return ok -} - -// GetActivityFacetFieldNames returns a slice of supported activity facet field names. -// Useful for error messages showing valid options. -func GetActivityFacetFieldNames() []string { - return sortedKeys(ActivityFacetFields) -} - -// activityFacetColumnMapping maps API field paths to ClickHouse column names for activities. -var activityFacetColumnMapping = map[string]string{ - "spec.actor.name": "actor_name", - "spec.actor.type": "actor_type", - "spec.resource.apiGroup": "api_group", - "spec.resource.kind": "resource_kind", - "spec.resource.namespace": "resource_namespace", - "spec.changeSource": "change_source", -} - -// GetActivityFacetColumn returns the ClickHouse column name for an activity facet field. -// Returns an error if the field is not supported. -func GetActivityFacetColumn(field string) (string, error) { - col, ok := activityFacetColumnMapping[field] - if !ok { - return "", fmt.Errorf("unsupported activity facet field: %s", field) - } - return col, nil -} - -// EventFacetFields defines the supported fields for Kubernetes Event facet queries. -// Keys are API field paths (as used in queries), values are human-readable descriptions. -var EventFacetFields = map[string]string{ - "regarding.kind": "The kind of resource the event is about (Pod, Deployment, etc.)", - "regarding.namespace": "The namespace of the regarding object", - "reason": "The event reason (Scheduled, Pulled, Created, etc.)", - "type": "The event type (Normal, Warning)", - "source.component": "The component that generated the event (kubelet, scheduler, etc.)", - "namespace": "The namespace of the event itself", -} - -// IsValidEventFacetField checks if a field is supported for event faceting. -func IsValidEventFacetField(field string) bool { - _, ok := EventFacetFields[field] - return ok -} - -// EventFacetFieldNames returns a sorted list of supported event facet field names. -func EventFacetFieldNames() []string { - return sortedKeys(EventFacetFields) -} - -// eventFacetColumnMapping maps API field paths to ClickHouse column names for events. -var eventFacetColumnMapping = map[string]string{ - "regarding.kind": "regarding_kind", - "regarding.namespace": "regarding_namespace", - "reason": "reason", - "type": "type", - "source.component": "source_component", - "namespace": "namespace", -} - -// GetEventFacetColumn returns the ClickHouse column name for an event facet field. -// Returns an error if the field is not supported. -func GetEventFacetColumn(field string) (string, error) { - col, ok := eventFacetColumnMapping[field] - if !ok { - return "", fmt.Errorf("unsupported event facet field: %s", field) - } - return col, nil -} - - -// GetEventFieldValue extracts a field value from a Kubernetes Event object -// given a ClickHouse column name. This is the shared implementation used by -// both the watch and registry layers to apply field-selector filters in memory. -func GetEventFieldValue(event *corev1.Event, column string) string { - switch column { - case "namespace": - return event.Namespace - case "name": - return event.Name - case "uid": - return string(event.UID) - case "regarding_api_version": - return event.InvolvedObject.APIVersion - case "regarding_kind": - return event.InvolvedObject.Kind - case "regarding_namespace": - return event.InvolvedObject.Namespace - case "regarding_name": - return event.InvolvedObject.Name - case "regarding_uid": - return string(event.InvolvedObject.UID) - case "regarding_field_path": - return event.InvolvedObject.FieldPath - case "reason": - return event.Reason - case "type": - return event.Type - case "source_component": - return event.Source.Component - case "source_host": - return event.Source.Host - default: - return "" - } -} - -func init() { - // Validate that all defined fields have corresponding column mappings. - // This catches mismatches at startup rather than at runtime. - for field := range AuditLogFacetFields { - if _, ok := auditLogFacetColumnMapping[field]; !ok { - panic(fmt.Sprintf("missing ClickHouse column mapping for audit log facet field %q", field)) - } - } - - // Also validate the reverse: all mappings should have field definitions - for field := range auditLogFacetColumnMapping { - if _, ok := AuditLogFacetFields[field]; !ok { - panic(fmt.Sprintf("audit log facet column mapping %q has no field definition", field)) - } - } - - // Validate activity facet fields - for field := range ActivityFacetFields { - if _, ok := activityFacetColumnMapping[field]; !ok { - panic(fmt.Sprintf("missing ClickHouse column mapping for activity facet field %q", field)) - } - } - - for field := range activityFacetColumnMapping { - if _, ok := ActivityFacetFields[field]; !ok { - panic(fmt.Sprintf("activity facet column mapping %q has no field definition", field)) - } - } - - // Validate event facet fields - for field := range EventFacetFields { - if _, ok := eventFacetColumnMapping[field]; !ok { - panic(fmt.Sprintf("missing ClickHouse column mapping for event facet field %q", field)) - } - } - - for field := range eventFacetColumnMapping { - if _, ok := EventFacetFields[field]; !ok { - panic(fmt.Sprintf("event facet column mapping %q has no field definition", field)) - } - } -} +package storage + +import ( + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" +) + +// AuditLogFacetFields defines the supported fields for audit log facet queries. +// Keys are API field paths (as used in queries), values are human-readable descriptions. +var AuditLogFacetFields = map[string]string{ + "verb": "The API verb (get, list, create, update, delete, etc.)", + "user.username": "The username of the actor", + "user.uid": "The UID of the actor", + "responseStatus.code": "The HTTP response status code", + "objectRef.namespace": "The namespace of the target object", + "objectRef.resource": "The resource type", + "objectRef.apiGroup": "The API group of the target resource", +} + +// IsValidAuditLogFacetField checks if a field is supported for audit log faceting. +func IsValidAuditLogFacetField(field string) bool { + _, ok := AuditLogFacetFields[field] + return ok +} + +// AuditLogFacetFieldNames returns a sorted list of supported audit log facet field names. +func AuditLogFacetFieldNames() []string { + return sortedKeys(AuditLogFacetFields) +} + +// FormatSupportedFields returns a comma-separated string of supported field names for error messages. +func FormatSupportedFields(fields map[string]string) string { + names := sortedKeys(fields) + return strings.Join(names, ", ") +} + +// sortedKeys returns the keys of a map in sorted order. +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// auditLogFacetColumnMapping maps API field paths to ClickHouse column names for audit logs. +// This is internal to the storage layer - only the field names are exposed publicly. +var auditLogFacetColumnMapping = map[string]string{ + "verb": "verb", + "user.username": "user", + "user.uid": "user_uid", + "responseStatus.code": "status_code", + "objectRef.namespace": "namespace", + "objectRef.resource": "resource", + "objectRef.apiGroup": "api_group", +} + +// GetAuditLogFacetColumn returns the ClickHouse column name for an audit log facet field. +// Returns an error if the field is not supported. +func GetAuditLogFacetColumn(field string) (string, error) { + col, ok := auditLogFacetColumnMapping[field] + if !ok { + return "", fmt.Errorf("unsupported audit log facet field: %s", field) + } + return col, nil +} + +// ActivityFacetFields defines the supported fields for activity facet queries. +// Keys are API field paths (as used in queries), values are human-readable descriptions. +var ActivityFacetFields = map[string]string{ + "spec.actor.name": "The name of the actor who performed the action", + "spec.actor.type": "The type of actor (user, service, system)", + "spec.resource.apiGroup": "The API group of the target resource", + "spec.resource.kind": "The kind of the target resource", + "spec.resource.namespace": "The namespace of the target resource", + "spec.changeSource": "The source of the change (human, automation, system)", +} + +// IsValidActivityFacetField checks if a field is supported for activity faceting. +func IsValidActivityFacetField(field string) bool { + _, ok := ActivityFacetFields[field] + return ok +} + +// GetActivityFacetFieldNames returns a slice of supported activity facet field names. +// Useful for error messages showing valid options. +func GetActivityFacetFieldNames() []string { + return sortedKeys(ActivityFacetFields) +} + +// activityFacetColumnMapping maps API field paths to ClickHouse column names for activities. +var activityFacetColumnMapping = map[string]string{ + "spec.actor.name": "actor_name", + "spec.actor.type": "actor_type", + "spec.resource.apiGroup": "api_group", + "spec.resource.kind": "resource_kind", + "spec.resource.namespace": "resource_namespace", + "spec.changeSource": "change_source", +} + +// GetActivityFacetColumn returns the ClickHouse column name for an activity facet field. +// Returns an error if the field is not supported. +func GetActivityFacetColumn(field string) (string, error) { + col, ok := activityFacetColumnMapping[field] + if !ok { + return "", fmt.Errorf("unsupported activity facet field: %s", field) + } + return col, nil +} + +// EventFacetFields defines the supported fields for Kubernetes Event facet queries. +// Keys are API field paths (as used in queries), values are human-readable descriptions. +var EventFacetFields = map[string]string{ + "regarding.kind": "The kind of resource the event is about (Pod, Deployment, etc.)", + "regarding.namespace": "The namespace of the regarding object", + "reason": "The event reason (Scheduled, Pulled, Created, etc.)", + "type": "The event type (Normal, Warning)", + "source.component": "The component that generated the event (kubelet, scheduler, etc.)", + "namespace": "The namespace of the event itself", +} + +// IsValidEventFacetField checks if a field is supported for event faceting. +func IsValidEventFacetField(field string) bool { + _, ok := EventFacetFields[field] + return ok +} + +// EventFacetFieldNames returns a sorted list of supported event facet field names. +func EventFacetFieldNames() []string { + return sortedKeys(EventFacetFields) +} + +// eventFacetColumnMapping maps API field paths to ClickHouse column names for events. +var eventFacetColumnMapping = map[string]string{ + "regarding.kind": "regarding_kind", + "regarding.namespace": "regarding_namespace", + "reason": "reason", + "type": "type", + "source.component": "source_component", + "namespace": "namespace", +} + +// GetEventFacetColumn returns the ClickHouse column name for an event facet field. +// Returns an error if the field is not supported. +func GetEventFacetColumn(field string) (string, error) { + col, ok := eventFacetColumnMapping[field] + if !ok { + return "", fmt.Errorf("unsupported event facet field: %s", field) + } + return col, nil +} + + +// GetEventFieldValue extracts a field value from a Kubernetes Event object +// given a ClickHouse column name. This is the shared implementation used by +// both the watch and registry layers to apply field-selector filters in memory. +func GetEventFieldValue(event *corev1.Event, column string) string { + switch column { + case "namespace": + return event.Namespace + case "name": + return event.Name + case "uid": + return string(event.UID) + case "regarding_api_version": + return event.InvolvedObject.APIVersion + case "regarding_kind": + return event.InvolvedObject.Kind + case "regarding_namespace": + return event.InvolvedObject.Namespace + case "regarding_name": + return event.InvolvedObject.Name + case "regarding_uid": + return string(event.InvolvedObject.UID) + case "regarding_field_path": + return event.InvolvedObject.FieldPath + case "reason": + return event.Reason + case "type": + return event.Type + case "source_component": + return event.Source.Component + case "source_host": + return event.Source.Host + default: + return "" + } +} + +func init() { + // Validate that all defined fields have corresponding column mappings. + // This catches mismatches at startup rather than at runtime. + for field := range AuditLogFacetFields { + if _, ok := auditLogFacetColumnMapping[field]; !ok { + panic(fmt.Sprintf("missing ClickHouse column mapping for audit log facet field %q", field)) + } + } + + // Also validate the reverse: all mappings should have field definitions + for field := range auditLogFacetColumnMapping { + if _, ok := AuditLogFacetFields[field]; !ok { + panic(fmt.Sprintf("audit log facet column mapping %q has no field definition", field)) + } + } + + // Validate activity facet fields + for field := range ActivityFacetFields { + if _, ok := activityFacetColumnMapping[field]; !ok { + panic(fmt.Sprintf("missing ClickHouse column mapping for activity facet field %q", field)) + } + } + + for field := range activityFacetColumnMapping { + if _, ok := ActivityFacetFields[field]; !ok { + panic(fmt.Sprintf("activity facet column mapping %q has no field definition", field)) + } + } + + // Validate event facet fields + for field := range EventFacetFields { + if _, ok := eventFacetColumnMapping[field]; !ok { + panic(fmt.Sprintf("missing ClickHouse column mapping for event facet field %q", field)) + } + } + + for field := range eventFacetColumnMapping { + if _, ok := EventFacetFields[field]; !ok { + panic(fmt.Sprintf("event facet column mapping %q has no field definition", field)) + } + } +} diff --git a/observability/dashboards/activity-processor.jsonnet b/observability/dashboards/activity-processor.jsonnet index db5aa484..b4b0b5f3 100644 --- a/observability/dashboards/activity-processor.jsonnet +++ b/observability/dashboards/activity-processor.jsonnet @@ -1,639 +1,639 @@ -// Activity Processor Grafana Dashboard -// Generated using Grafonnet v11.4.0 -// To build: jsonnet -J vendor dashboards/activity-processor.jsonnet > ../config/components/observability/dashboards/generated/activity-processor.json - -local g = import 'grafonnet-v11.4.0/main.libsonnet'; -local config = import '../config.libsonnet'; - -local dashboard = g.dashboard; -local panel = g.panel; -local stat = panel.stat; -local timeSeries = panel.timeSeries; -local row = panel.row; -local prometheus = g.query.prometheus; -local util = g.util; - -// Configuration -local datasource = config.dashboards.datasource.name; -local datasourceRegex = config.dashboards.datasource.regex; -local refresh = config.dashboards.refresh; - -// Panel dimension constants -local statHeight = 5; -local statWidth = 6; -local timeSeriesHeight = 8; -local timeSeriesHalfWidth = 12; - -// Build all panels using wrapPanels for auto-layout -local allPanels = util.grid.wrapPanels([ - // ============================================================================ - // Row 1: Overview Stats - // ============================================================================ - stat.new('Event Processing Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_events_received_total[5m]))' - ) - + prometheus.withLegendFormat('Events/s'), - ]) - + stat.panelOptions.withDescription('Rate of events received from NATS per second') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('Activity Generation Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_activities_generated_total[5m]))' - ) - + prometheus.withLegendFormat('Activities/s'), - ]) - + stat.panelOptions.withDescription('Rate of activities generated and published to NATS') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('Error Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('percent') - + stat.standardOptions.withDecimals(2) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - '(sum(rate(activity_processor_events_errored_total[5m])) or vector(0)) / (sum(rate(activity_processor_events_received_total[5m])) or vector(1)) * 100' - ) - + prometheus.withLegendFormat('Error %'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 1 }, - { color: 'red', value: 5 }, - ]) - + stat.panelOptions.withDescription('Percentage of events that resulted in errors during processing') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('Active Policies') - + stat.options.withGraphMode('none') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('short') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'activity_processor_active_policies' - ) - + prometheus.withInstant(true) - + prometheus.withLegendFormat('Policies'), - ]) - + stat.panelOptions.withDescription('Number of ActivityPolicies currently loaded') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - // ============================================================================ - // Row 2: Event Processing - // ============================================================================ - row.new('Event Processing') - + row.withCollapsed(false), - - timeSeries.new('Events by API Group') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_events_received_total[5m])) by (api_group)' - ) - + prometheus.withLegendFormat('{{api_group}}'), - ]) - + timeSeries.panelOptions.withDescription('Event processing rate by Kubernetes API group') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('Events Evaluated vs Generated') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_events_evaluated_total[5m]))' - ) - + prometheus.withLegendFormat('Evaluated'), - prometheus.new( - datasource, - 'sum(rate(activity_processor_activities_generated_total[5m]))' - ) - + prometheus.withLegendFormat('Generated'), - ]) - + timeSeries.panelOptions.withDescription('Events evaluated against policies vs activities generated (conversion rate)') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('Skipped Events by Reason') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_events_skipped_total[5m])) by (reason)' - ) - + prometheus.withLegendFormat('{{reason}}'), - ]) - + timeSeries.panelOptions.withDescription('Events skipped during processing, grouped by skip reason') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('Processing Duration p99 by Policy') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) - + timeSeries.standardOptions.withUnit('s') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - // Note: Using event_processing_duration_seconds (actual metric name) with 'policy' label (not policy_name) - 'histogram_quantile(0.99, sum(rate(activity_processor_event_processing_duration_seconds_bucket[5m])) by (policy, le))' - ) - + prometheus.withLegendFormat('{{policy}}'), - ]) - + timeSeries.panelOptions.withDescription('99th percentile processing duration per policy') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - // ============================================================================ - // Row 3: NATS Health - // ============================================================================ - row.new('NATS Health') - + row.withCollapsed(false), - - stat.new('NATS Connection Status') - + stat.options.withGraphMode('none') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - // Use min() so panel shows Disconnected if ANY instance is disconnected - // Filter by job to exclude k8s-event-exporter which also exports this metric - 'min(activity_processor_nats_connection_status{job="activity-processor"})' - ) - + prometheus.withInstant(true) - + prometheus.withLegendFormat('Connected'), - ]) - + stat.standardOptions.withMappings([ - { - type: 'value', - options: { - '0': { text: 'Disconnected', color: 'red' }, - '1': { text: 'Connected', color: 'green' }, - }, - }, - ]) - + stat.panelOptions.withDescription('Current NATS connection status (shows Disconnected if any instance is disconnected)') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('NATS Disconnects') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('short') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - // Sum across all instances for total disconnect count - 'sum(activity_processor_nats_disconnects_total)' - ) - + prometheus.withLegendFormat('Disconnects'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 1 }, - { color: 'red', value: 5 }, - ]) - + stat.panelOptions.withDescription('Total number of NATS disconnection events across all instances') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('NATS Publish Latency p99') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('s') - + stat.standardOptions.withDecimals(3) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p99'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'red', value: 0.5 }, - ]) - + stat.panelOptions.withDescription('99th percentile latency for NATS message publishing') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('Messages Published Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_nats_messages_published_total[5m]))' - ) - + prometheus.withLegendFormat('Messages/s'), - ]) - + stat.panelOptions.withDescription('Rate of messages published to NATS per second') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - timeSeries.new('NATS Connection Events') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_nats_disconnects_total[5m]))' - ) - + prometheus.withLegendFormat('Disconnects'), - prometheus.new( - datasource, - 'sum(rate(activity_processor_nats_reconnects_total[5m]))' - ) - + prometheus.withLegendFormat('Reconnects'), - prometheus.new( - datasource, - 'sum(rate(activity_processor_nats_errors_total[5m]))' - ) - + prometheus.withLegendFormat('Errors'), - ]) - + timeSeries.panelOptions.withDescription('NATS connection events over time (aggregated across all instances)') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('NATS Publish Latency Percentiles') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) - + timeSeries.standardOptions.withUnit('s') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p99'), - prometheus.new( - datasource, - 'histogram_quantile(0.95, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p95'), - prometheus.new( - datasource, - 'histogram_quantile(0.50, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p50'), - ]) - + timeSeries.panelOptions.withDescription('NATS publish latency distribution') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - // ============================================================================ - // Row 4: Worker Health - // ============================================================================ - row.new('Worker Health') - + row.withCollapsed(false), - - timeSeries.new('Active Workers') - + timeSeries.options.legend.withDisplayMode('list') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.standardOptions.withUnit('short') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(activity_processor_active_workers)' - ) - + prometheus.withLegendFormat('Total Workers'), - ]) - + timeSeries.panelOptions.withDescription('Total number of active worker goroutines across all processor instances') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('Error Types Breakdown') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_events_errored_total[5m])) by (error_type) or vector(0)' - ) - + prometheus.withLegendFormat('{{error_type}}'), - ]) - + timeSeries.panelOptions.withDescription('Processing errors broken down by error type') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - // ============================================================================ - // Row 5: Dead Letter Queue - // ============================================================================ - row.new('Dead Letter Queue') - + row.withCollapsed(false), - - stat.new('DLQ Publish Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_dlq_events_published_total[5m])) or vector(0)' - ) - + prometheus.withLegendFormat('Events/s'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'red', value: 1 }, - ]) - + stat.panelOptions.withDescription('Rate of events published to the dead letter queue per second') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('DLQ Publish Errors') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_dlq_publish_errors_total[5m])) or vector(0)' - ) - + prometheus.withLegendFormat('Errors/s'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'red', value: 0.01 }, - ]) - + stat.panelOptions.withDescription('Rate of errors encountered when publishing to the dead letter queue') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('Retry Success Rate') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('percentunit') - + stat.standardOptions.withDecimals(1) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - '(sum(rate(activity_processor_dlq_retry_attempts_total{result="succeeded"}[5m])) or vector(0)) / clamp_min(sum(rate(activity_processor_dlq_retry_attempts_total[5m])), 1)' - ) - + prometheus.withLegendFormat('Success Rate'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'red', value: null }, - { color: 'yellow', value: 0.8 }, - { color: 'green', value: 0.95 }, - ]) - + stat.panelOptions.withDescription('Fraction of DLQ retry attempts that succeeded') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - stat.new('High Retry Events') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('short') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid(datasource) - + stat.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(increase(activity_processor_dlq_retry_events_high_retry_total[1h])) or vector(0)' - ) - + prometheus.withLegendFormat('Events'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'red', value: 1 }, - ]) - + stat.panelOptions.withDescription('DLQ events exceeding the high retry threshold in the last hour') - + stat.gridPos.withW(statWidth) - + stat.gridPos.withH(statHeight), - - timeSeries.new('DLQ Events by Error Type') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_dlq_events_published_total[5m])) by (error_type)' - ) - + prometheus.withLegendFormat('{{error_type}}'), - ]) - + timeSeries.panelOptions.withDescription('Rate of DLQ events published, broken down by error type') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('DLQ Events by Policy') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'label_replace(sum(rate(activity_processor_dlq_events_published_total[5m])) by (policy_name), "policy_name", "(no policy)", "policy_name", "^$")' - ) - + prometheus.withLegendFormat('{{policy_name}}'), - ]) - + timeSeries.panelOptions.withDescription('Rate of DLQ events published, broken down by policy name') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('Retry Attempts') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'sum(rate(activity_processor_dlq_retry_attempts_total[5m])) by (trigger, result)' - ) - + prometheus.withLegendFormat('{{trigger}} - {{result}}'), - ]) - + timeSeries.panelOptions.withDescription('Rate of DLQ retry attempts, broken down by trigger source and result') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), - - timeSeries.new('DLQ Publish Latency') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) - + timeSeries.standardOptions.withUnit('s') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid(datasource) - + timeSeries.queryOptions.withTargets([ - prometheus.new( - datasource, - 'histogram_quantile(0.99, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p99'), - prometheus.new( - datasource, - 'histogram_quantile(0.95, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p95'), - prometheus.new( - datasource, - 'histogram_quantile(0.50, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' - ) - + prometheus.withLegendFormat('p50'), - ]) - + timeSeries.panelOptions.withDescription('DLQ publish latency distribution (p99, p95, p50)') - + timeSeries.gridPos.withW(timeSeriesHalfWidth) - + timeSeries.gridPos.withH(timeSeriesHeight), -]); - -// Dashboard -dashboard.new('Activity Processor - Event Pipeline') -+ dashboard.withDescription('Activity Processor metrics for event processing, policy evaluation, and NATS health') -+ dashboard.withTags(['activity', 'processor', 'pipeline', 'nats']) -+ dashboard.withUid('activity-processor') -+ dashboard.time.withFrom('now-24h') -+ dashboard.withRefresh(refresh) -+ dashboard.withEditable(true) -+ dashboard.graphTooltip.withSharedCrosshair() -+ dashboard.withVariables([ - g.dashboard.variable.datasource.new('datasource', 'prometheus') - + g.dashboard.variable.datasource.generalOptions.withLabel('Prometheus Datasource') - + g.dashboard.variable.datasource.withRegex(datasourceRegex), -]) -+ dashboard.withPanels(allPanels) +// Activity Processor Grafana Dashboard +// Generated using Grafonnet v11.4.0 +// To build: jsonnet -J vendor dashboards/activity-processor.jsonnet > ../config/components/observability/dashboards/generated/activity-processor.json + +local g = import 'grafonnet-v11.4.0/main.libsonnet'; +local config = import '../config.libsonnet'; + +local dashboard = g.dashboard; +local panel = g.panel; +local stat = panel.stat; +local timeSeries = panel.timeSeries; +local row = panel.row; +local prometheus = g.query.prometheus; +local util = g.util; + +// Configuration +local datasource = config.dashboards.datasource.name; +local datasourceRegex = config.dashboards.datasource.regex; +local refresh = config.dashboards.refresh; + +// Panel dimension constants +local statHeight = 5; +local statWidth = 6; +local timeSeriesHeight = 8; +local timeSeriesHalfWidth = 12; + +// Build all panels using wrapPanels for auto-layout +local allPanels = util.grid.wrapPanels([ + // ============================================================================ + // Row 1: Overview Stats + // ============================================================================ + stat.new('Event Processing Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_events_received_total[5m]))' + ) + + prometheus.withLegendFormat('Events/s'), + ]) + + stat.panelOptions.withDescription('Rate of events received from NATS per second') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('Activity Generation Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_activities_generated_total[5m]))' + ) + + prometheus.withLegendFormat('Activities/s'), + ]) + + stat.panelOptions.withDescription('Rate of activities generated and published to NATS') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('Error Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('percent') + + stat.standardOptions.withDecimals(2) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + '(sum(rate(activity_processor_events_errored_total[5m])) or vector(0)) / (sum(rate(activity_processor_events_received_total[5m])) or vector(1)) * 100' + ) + + prometheus.withLegendFormat('Error %'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 1 }, + { color: 'red', value: 5 }, + ]) + + stat.panelOptions.withDescription('Percentage of events that resulted in errors during processing') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('Active Policies') + + stat.options.withGraphMode('none') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('short') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'activity_processor_active_policies' + ) + + prometheus.withInstant(true) + + prometheus.withLegendFormat('Policies'), + ]) + + stat.panelOptions.withDescription('Number of ActivityPolicies currently loaded') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + // ============================================================================ + // Row 2: Event Processing + // ============================================================================ + row.new('Event Processing') + + row.withCollapsed(false), + + timeSeries.new('Events by API Group') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_events_received_total[5m])) by (api_group)' + ) + + prometheus.withLegendFormat('{{api_group}}'), + ]) + + timeSeries.panelOptions.withDescription('Event processing rate by Kubernetes API group') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('Events Evaluated vs Generated') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_events_evaluated_total[5m]))' + ) + + prometheus.withLegendFormat('Evaluated'), + prometheus.new( + datasource, + 'sum(rate(activity_processor_activities_generated_total[5m]))' + ) + + prometheus.withLegendFormat('Generated'), + ]) + + timeSeries.panelOptions.withDescription('Events evaluated against policies vs activities generated (conversion rate)') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('Skipped Events by Reason') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_events_skipped_total[5m])) by (reason)' + ) + + prometheus.withLegendFormat('{{reason}}'), + ]) + + timeSeries.panelOptions.withDescription('Events skipped during processing, grouped by skip reason') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('Processing Duration p99 by Policy') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) + + timeSeries.standardOptions.withUnit('s') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + // Note: Using event_processing_duration_seconds (actual metric name) with 'policy' label (not policy_name) + 'histogram_quantile(0.99, sum(rate(activity_processor_event_processing_duration_seconds_bucket[5m])) by (policy, le))' + ) + + prometheus.withLegendFormat('{{policy}}'), + ]) + + timeSeries.panelOptions.withDescription('99th percentile processing duration per policy') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + // ============================================================================ + // Row 3: NATS Health + // ============================================================================ + row.new('NATS Health') + + row.withCollapsed(false), + + stat.new('NATS Connection Status') + + stat.options.withGraphMode('none') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + // Use min() so panel shows Disconnected if ANY instance is disconnected + // Filter by job to exclude k8s-event-exporter which also exports this metric + 'min(activity_processor_nats_connection_status{job="activity-processor"})' + ) + + prometheus.withInstant(true) + + prometheus.withLegendFormat('Connected'), + ]) + + stat.standardOptions.withMappings([ + { + type: 'value', + options: { + '0': { text: 'Disconnected', color: 'red' }, + '1': { text: 'Connected', color: 'green' }, + }, + }, + ]) + + stat.panelOptions.withDescription('Current NATS connection status (shows Disconnected if any instance is disconnected)') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('NATS Disconnects') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('short') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + // Sum across all instances for total disconnect count + 'sum(activity_processor_nats_disconnects_total)' + ) + + prometheus.withLegendFormat('Disconnects'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 1 }, + { color: 'red', value: 5 }, + ]) + + stat.panelOptions.withDescription('Total number of NATS disconnection events across all instances') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('NATS Publish Latency p99') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('s') + + stat.standardOptions.withDecimals(3) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p99'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'red', value: 0.5 }, + ]) + + stat.panelOptions.withDescription('99th percentile latency for NATS message publishing') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('Messages Published Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_nats_messages_published_total[5m]))' + ) + + prometheus.withLegendFormat('Messages/s'), + ]) + + stat.panelOptions.withDescription('Rate of messages published to NATS per second') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + timeSeries.new('NATS Connection Events') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_nats_disconnects_total[5m]))' + ) + + prometheus.withLegendFormat('Disconnects'), + prometheus.new( + datasource, + 'sum(rate(activity_processor_nats_reconnects_total[5m]))' + ) + + prometheus.withLegendFormat('Reconnects'), + prometheus.new( + datasource, + 'sum(rate(activity_processor_nats_errors_total[5m]))' + ) + + prometheus.withLegendFormat('Errors'), + ]) + + timeSeries.panelOptions.withDescription('NATS connection events over time (aggregated across all instances)') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('NATS Publish Latency Percentiles') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) + + timeSeries.standardOptions.withUnit('s') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'histogram_quantile(0.99, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p99'), + prometheus.new( + datasource, + 'histogram_quantile(0.95, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p95'), + prometheus.new( + datasource, + 'histogram_quantile(0.50, sum(rate(activity_processor_nats_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p50'), + ]) + + timeSeries.panelOptions.withDescription('NATS publish latency distribution') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + // ============================================================================ + // Row 4: Worker Health + // ============================================================================ + row.new('Worker Health') + + row.withCollapsed(false), + + timeSeries.new('Active Workers') + + timeSeries.options.legend.withDisplayMode('list') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.standardOptions.withUnit('short') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(activity_processor_active_workers)' + ) + + prometheus.withLegendFormat('Total Workers'), + ]) + + timeSeries.panelOptions.withDescription('Total number of active worker goroutines across all processor instances') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('Error Types Breakdown') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_events_errored_total[5m])) by (error_type) or vector(0)' + ) + + prometheus.withLegendFormat('{{error_type}}'), + ]) + + timeSeries.panelOptions.withDescription('Processing errors broken down by error type') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + // ============================================================================ + // Row 5: Dead Letter Queue + // ============================================================================ + row.new('Dead Letter Queue') + + row.withCollapsed(false), + + stat.new('DLQ Publish Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_dlq_events_published_total[5m])) or vector(0)' + ) + + prometheus.withLegendFormat('Events/s'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'red', value: 1 }, + ]) + + stat.panelOptions.withDescription('Rate of events published to the dead letter queue per second') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('DLQ Publish Errors') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_dlq_publish_errors_total[5m])) or vector(0)' + ) + + prometheus.withLegendFormat('Errors/s'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'red', value: 0.01 }, + ]) + + stat.panelOptions.withDescription('Rate of errors encountered when publishing to the dead letter queue') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('Retry Success Rate') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('percentunit') + + stat.standardOptions.withDecimals(1) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + '(sum(rate(activity_processor_dlq_retry_attempts_total{result="succeeded"}[5m])) or vector(0)) / clamp_min(sum(rate(activity_processor_dlq_retry_attempts_total[5m])), 1)' + ) + + prometheus.withLegendFormat('Success Rate'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'red', value: null }, + { color: 'yellow', value: 0.8 }, + { color: 'green', value: 0.95 }, + ]) + + stat.panelOptions.withDescription('Fraction of DLQ retry attempts that succeeded') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + stat.new('High Retry Events') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('short') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid(datasource) + + stat.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(increase(activity_processor_dlq_retry_events_high_retry_total[1h])) or vector(0)' + ) + + prometheus.withLegendFormat('Events'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'red', value: 1 }, + ]) + + stat.panelOptions.withDescription('DLQ events exceeding the high retry threshold in the last hour') + + stat.gridPos.withW(statWidth) + + stat.gridPos.withH(statHeight), + + timeSeries.new('DLQ Events by Error Type') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_dlq_events_published_total[5m])) by (error_type)' + ) + + prometheus.withLegendFormat('{{error_type}}'), + ]) + + timeSeries.panelOptions.withDescription('Rate of DLQ events published, broken down by error type') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('DLQ Events by Policy') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'label_replace(sum(rate(activity_processor_dlq_events_published_total[5m])) by (policy_name), "policy_name", "(no policy)", "policy_name", "^$")' + ) + + prometheus.withLegendFormat('{{policy_name}}'), + ]) + + timeSeries.panelOptions.withDescription('Rate of DLQ events published, broken down by policy name') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('Retry Attempts') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'sum(rate(activity_processor_dlq_retry_attempts_total[5m])) by (trigger, result)' + ) + + prometheus.withLegendFormat('{{trigger}} - {{result}}'), + ]) + + timeSeries.panelOptions.withDescription('Rate of DLQ retry attempts, broken down by trigger source and result') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), + + timeSeries.new('DLQ Publish Latency') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['lastNotNull', 'mean', 'max']) + + timeSeries.standardOptions.withUnit('s') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid(datasource) + + timeSeries.queryOptions.withTargets([ + prometheus.new( + datasource, + 'histogram_quantile(0.99, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p99'), + prometheus.new( + datasource, + 'histogram_quantile(0.95, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p95'), + prometheus.new( + datasource, + 'histogram_quantile(0.50, sum(rate(activity_processor_dlq_publish_latency_seconds_bucket[5m])) by (le))' + ) + + prometheus.withLegendFormat('p50'), + ]) + + timeSeries.panelOptions.withDescription('DLQ publish latency distribution (p99, p95, p50)') + + timeSeries.gridPos.withW(timeSeriesHalfWidth) + + timeSeries.gridPos.withH(timeSeriesHeight), +]); + +// Dashboard +dashboard.new('Activity Processor - Event Pipeline') ++ dashboard.withDescription('Activity Processor metrics for event processing, policy evaluation, and NATS health') ++ dashboard.withTags(['activity', 'processor', 'pipeline', 'nats']) ++ dashboard.withUid('activity-processor') ++ dashboard.time.withFrom('now-24h') ++ dashboard.withRefresh(refresh) ++ dashboard.withEditable(true) ++ dashboard.graphTooltip.withSharedCrosshair() ++ dashboard.withVariables([ + g.dashboard.variable.datasource.new('datasource', 'prometheus') + + g.dashboard.variable.datasource.generalOptions.withLabel('Prometheus Datasource') + + g.dashboard.variable.datasource.withRegex(datasourceRegex), +]) ++ dashboard.withPanels(allPanels) diff --git a/observability/dashboards/events-pipeline.jsonnet b/observability/dashboards/events-pipeline.jsonnet index 1931c2a9..78c36c12 100644 --- a/observability/dashboards/events-pipeline.jsonnet +++ b/observability/dashboards/events-pipeline.jsonnet @@ -1,493 +1,493 @@ -// Control Plane Events Pipeline Dashboard -// Generated using Grafonnet v11.4.0 -// To build: jsonnet -J vendor dashboards/events-pipeline.jsonnet > ../config/components/observability/dashboards/generated/events-pipeline.json - -local g = import 'grafonnet-v11.4.0/main.libsonnet'; -local config = import '../config.libsonnet'; - -local dashboard = g.dashboard; -local panel = g.panel; -local stat = panel.stat; -local timeSeries = panel.timeSeries; -local row = panel.row; -local prometheus = g.query.prometheus; -local util = g.util; - -// Configuration -local datasource = config.dashboards.datasource.name; -local datasourceRegex = config.dashboards.datasource.regex; -local refresh = config.dashboards.refresh; - -// Reusable Queries -local queries = { - // Pipeline flow: [Activity API Server OR k8s-event-exporter] → NATS → Vector → ClickHouse - // - // Metrics support both production (apiserver) and dev (k8s-event-exporter) environments. - // Queries use `or` to combine both sources - whichever is running provides the data. - - // Event ingestion rate (from either apiserver or k8s-event-exporter) - eventsPublishedRate: '(sum(rate(activity_events_published_total[5m])) or vector(0)) + (sum(rate(event_exporter_events_published_total[5m])) or vector(0))', - eventsPublishedByNamespace: 'sum(rate(activity_events_published_total[5m])) by (namespace) or sum(rate(event_exporter_events_published_total[5m])) by (namespace)', - eventsPublishedByReason: 'sum(rate(activity_events_published_total[5m])) by (reason) or sum(rate(event_exporter_events_published_total[5m])) by (reason)', - - // Publish latency (from either apiserver or k8s-event-exporter) - publishLatencyP99: 'histogram_quantile(0.99, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.99, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', - publishLatencyP95: 'histogram_quantile(0.95, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.95, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', - publishLatencyP50: 'histogram_quantile(0.50, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.50, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', - - // Ingestion errors (from either source) - publishErrors: '(sum(rate(activity_events_publish_errors_total[5m])) or vector(0)) + (sum(rate(event_exporter_publish_errors_total[5m])) or vector(0))', - - // Ingestion source health (shows status from whichever source is active) - // In dev: k8s-event-exporter NATS connection status - // In prod: activity-apiserver events NATS connection status - ingestionSourceStatus: 'min(activity_events_nats_connection_status) or min(event_exporter_nats_connection_status{job="k8s-event-exporter"})', - exporterConnectionStatus: 'min(event_exporter_nats_connection_status{job="k8s-event-exporter"})', - informerSyncStatus: 'min(event_exporter_informer_synced{job="k8s-event-exporter"})', - - // NATS metrics (events stream) - same in all environments - natsEventsStreamMessages: 'sum(rate(nats_stream_total_messages{stream_name="EVENTS"}[5m]))', - natsEventsQueuePending: 'sum(nats_consumer_num_pending{consumer_name="clickhouse-ingest-events"}) or vector(0)', - natsEventsQueueAckPending: 'sum(nats_consumer_num_ack_pending{consumer_name="clickhouse-ingest-events"}) or vector(0)', - - // Vector metrics (events consumer → ClickHouse) - same in all environments - vectorNatsEventsReceived: 'sum(rate(vector_component_received_events_total{component_id="nats_events_consumer",namespace="activity-system"}[5m]))', - vectorClickhouseEventsSent: 'sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m]))', - vectorEventsErrors: 'sum(rate(vector_component_errors_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) or vector(0)', - vectorEventsBufferDepth: 'sum(vector_buffer_events{component_id="clickhouse_k8s_events",namespace="activity-system"}) or vector(0)', - - // ClickHouse metrics (k8s_events table) - same in all environments - clickhouseEventsWriteRate: 'activity:clickhouse_events_insert_rate:5m', - clickhouseEventsInsertLatency: 'activity:clickhouse_events_insert_latency', - clickhouseEventsTableRows: 'sum(chi_clickhouse_table_parts_rows{chi="activity-clickhouse",database="audit",table="k8s_events",active="1"})', - clickhouseEventsTableParts: 'avg(chi_clickhouse_table_parts{chi="activity-clickhouse",database="audit",table="k8s_events"})', - - // Activity API Server - EventQuery metrics (read path) - eventQueryRate: 'sum(rate(apiserver_request_total{job="activity-apiserver",resource="eventqueries"}[5m])) or vector(0)', - eventQueryLatencyP99: 'histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver",resource="eventqueries"}[5m])) by (le))', - eventQueryErrors: 'sum(rate(apiserver_request_total{job="activity-apiserver",resource="eventqueries",code=~"5.."}[5m])) or vector(0)', - - // Combined pipeline health - pipelineErrorRate: '(sum(rate(activity_events_publish_errors_total[5m])) or vector(0)) + (sum(rate(event_exporter_publish_errors_total[5m])) or vector(0)) + (sum(rate(vector_component_errors_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) or vector(0))', -}; - -// Build all panels -local allPanels = - // ============================================================================ - // Row 1: Critical Health Indicators - // ============================================================================ - [ - row.new('Critical Health Indicators') - + row.withCollapsed(false) - + row.gridPos.withH(1) - + row.gridPos.withW(24) - + row.gridPos.withX(0) - + row.gridPos.withY(0), - ] - + util.grid.makeGrid([ - stat.new('Events Ingested') - + stat.panelOptions.withDescription('Events/sec published to NATS (from apiserver or event-exporter)') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.eventsPublishedRate) - + prometheus.withLegendFormat('Events/sec'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'red', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'green', value: 1 }, - ]), - - stat.new('Events Written Rate') - + stat.panelOptions.withDescription('Events/sec written to ClickHouse k8s_events table') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('value') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.clickhouseEventsWriteRate) - + prometheus.withLegendFormat('Events/sec'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'red', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'green', value: 1 }, - ]), - - stat.new('Queue Backlog') - + stat.panelOptions.withDescription('Pending events in NATS queue (backpressure indicator)') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('short') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.natsEventsQueuePending) - + prometheus.withLegendFormat('Pending'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 100 }, - { color: 'red', value: 1000 }, - ]), - - stat.new('Error Rate') - + stat.panelOptions.withDescription('Combined errors across event exporter and Vector') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('ops') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.pipelineErrorRate) - + prometheus.withLegendFormat('Errors/sec'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'red', value: 1 }, - ]), - - stat.new('Ingestion Source') - + stat.panelOptions.withDescription('Health of event ingestion source (apiserver or event-exporter)') - + stat.options.withTextMode('value_and_name') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.ingestionSourceStatus) - + prometheus.withLegendFormat('Status'), - ]) - + stat.standardOptions.withMappings([ - { type: 'value', options: { '0': { text: 'Unhealthy', color: 'red' } } }, - { type: 'value', options: { '1': { text: 'Healthy', color: 'green' } } }, - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'red', value: null }, - { color: 'green', value: 1 }, - ]), - - stat.new('Vector Buffer') - + stat.panelOptions.withDescription('Events buffered in Vector before ClickHouse write') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.standardOptions.withUnit('short') - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.vectorEventsBufferDepth) - + prometheus.withLegendFormat('Buffered'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 100 }, - { color: 'red', value: 1000 }, - ]), - - stat.new('ClickHouse Insert Latency') - + stat.panelOptions.withDescription('Average time to write events to k8s_events table') - + stat.options.withGraphMode('area') - + stat.options.withColorMode('background') - + stat.standardOptions.withUnit('s') - + stat.options.reduceOptions.withCalcs(['lastNotNull']) - + stat.datasource.withType('prometheus') - + stat.datasource.withUid('$datasource') - + stat.queryOptions.withTargets([ - prometheus.new('$datasource', queries.clickhouseEventsInsertLatency) - + prometheus.withLegendFormat('Insert Latency'), - ]) - + stat.standardOptions.thresholds.withSteps([ - { color: 'green', value: null }, - { color: 'yellow', value: 0.1 }, - { color: 'orange', value: 0.5 }, - { color: 'red', value: 1.0 }, - ]), - ], panelWidth=3, panelHeight=4, startY=1) - - // ============================================================================ - // Row 2: Event Ingestion - // ============================================================================ - + [ - row.new('Event Ingestion') - + row.withCollapsed(false) - + row.gridPos.withH(1) - + row.gridPos.withW(24) - + row.gridPos.withX(0) - + row.gridPos.withY(5), - ] - + util.grid.makeGrid([ - timeSeries.new('Events by Namespace') - + timeSeries.panelOptions.withDescription('Top 10 namespaces generating events') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', 'topk(10, ' + queries.eventsPublishedByNamespace + ')') - + prometheus.withLegendFormat('{{namespace}}'), - ]), - - timeSeries.new('Events by Reason') - + timeSeries.panelOptions.withDescription('Common event reasons (Created, Scheduled, Pulling, etc.)') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.eventsPublishedByReason) - + prometheus.withLegendFormat('{{reason}}'), - ]), - - timeSeries.new('Publish Latency (p50/p95/p99)') - + timeSeries.panelOptions.withDescription('Latency distribution for publishing events to NATS') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('s') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.publishLatencyP99) - + prometheus.withLegendFormat('p99'), - prometheus.new('$datasource', queries.publishLatencyP95) - + prometheus.withLegendFormat('p95'), - prometheus.new('$datasource', queries.publishLatencyP50) - + prometheus.withLegendFormat('p50'), - ]), - ], panelWidth=8, panelHeight=7, startY=6) - - // ============================================================================ - // Row 3: Pipeline Flow - // ============================================================================ - + [ - row.new('Pipeline Flow') - + row.withCollapsed(false) - + row.gridPos.withH(1) - + row.gridPos.withW(24) - + row.gridPos.withX(0) - + row.gridPos.withY(13), - ] - + util.grid.makeGrid([ - timeSeries.new('Event Flow Through Stages') - + timeSeries.panelOptions.withDescription('Events/sec at each pipeline stage - should be roughly equal in steady state') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.eventsPublishedRate) - + prometheus.withLegendFormat('1. Published to NATS'), - prometheus.new('$datasource', queries.vectorNatsEventsReceived) - + prometheus.withLegendFormat('2. Consumed from NATS'), - prometheus.new('$datasource', queries.vectorClickhouseEventsSent) - + prometheus.withLegendFormat('3. Sent to ClickHouse'), - prometheus.new('$datasource', queries.clickhouseEventsWriteRate) - + prometheus.withLegendFormat('4. ClickHouse Writes'), - ]), - - timeSeries.new('Ingress vs Egress Comparison') - + timeSeries.panelOptions.withDescription('Pipeline input vs output - gap indicates bottleneck or loss') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(0) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.eventsPublishedRate) - + prometheus.withLegendFormat('Ingress (Published)'), - prometheus.new('$datasource', queries.clickhouseEventsWriteRate) - + prometheus.withLegendFormat('Egress (ClickHouse)'), - ]), - ], panelWidth=12, panelHeight=8, startY=14) - - // ============================================================================ - // Row 4: Performance - // ============================================================================ - + [ - row.new('Performance') - + row.withCollapsed(false) - + row.gridPos.withH(1) - + row.gridPos.withW(24) - + row.gridPos.withX(0) - + row.gridPos.withY(22), - ] - + util.grid.makeGrid([ - timeSeries.new('NATS Consumer Lag') - + timeSeries.panelOptions.withDescription('Pending messages for events consumer - indicates backpressure') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('short') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(20) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.natsEventsQueuePending) - + prometheus.withLegendFormat('Pending Messages'), - ]), - - timeSeries.new('Vector Buffer Depth') - + timeSeries.panelOptions.withDescription('Buffer depth indicates ClickHouse backpressure - high values mean slow writes') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('short') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.vectorEventsBufferDepth) - + prometheus.withLegendFormat('Buffered Events'), - ]), - - timeSeries.new('ClickHouse Insert Performance') - + timeSeries.panelOptions.withDescription('Events insert rate and latency - write path health for k8s_events table') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.clickhouseEventsWriteRate) - + prometheus.withLegendFormat('Events Inserted/sec'), - ]), - - timeSeries.new('Publish Errors Over Time') - + timeSeries.panelOptions.withDescription('Errors publishing events to NATS - should be ZERO in healthy state') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.publishErrors) - + prometheus.withLegendFormat('Publish Errors'), - ]), - ], panelWidth=6, panelHeight=7, startY=23) - - // ============================================================================ - // Row 5: Error Breakdown (collapsed) - // ============================================================================ - + [ - row.new('Error Breakdown') - + row.withCollapsed(true) - + row.gridPos.withH(1) - + row.gridPos.withW(24) - + row.gridPos.withX(0) - + row.gridPos.withY(30) - + row.withPanels( - util.grid.makeGrid([ - timeSeries.new('Errors by Component') - + timeSeries.panelOptions.withDescription('Breakdown of errors: Ingestion source vs Vector') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('right') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', queries.publishErrors) - + prometheus.withLegendFormat('Ingestion Errors'), - prometheus.new('$datasource', queries.vectorEventsErrors) - + prometheus.withLegendFormat('Vector Errors'), - ]), - - timeSeries.new('Vector Component Errors') - + timeSeries.panelOptions.withDescription('Detailed Vector pipeline errors for events stream') - + timeSeries.options.legend.withDisplayMode('table') - + timeSeries.options.legend.withPlacement('bottom') - + timeSeries.options.legend.withShowLegend(true) - + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) - + timeSeries.standardOptions.withUnit('ops') - + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) - + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) - + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') - + timeSeries.datasource.withType('prometheus') - + timeSeries.datasource.withUid('$datasource') - + timeSeries.queryOptions.withTargets([ - prometheus.new('$datasource', 'sum(rate(vector_component_errors_total{component_id=~".*events.*"}[5m])) by (component_id) or vector(0)') - + prometheus.withLegendFormat('{{component_id}}'), - ]), - ], panelWidth=12, panelHeight=8, startY=31) - ), - ]; - -// Dashboard -dashboard.new('Control Plane Events Pipeline') -+ dashboard.withDescription('End-to-end monitoring of K8s events: [Activity API Server|k8s-event-exporter] → NATS → Vector → ClickHouse. Works with both production (apiserver) and dev (event-exporter) environments.') -+ dashboard.withTags(['events', 'pipeline', 'activity', 'observability']) -+ dashboard.withUid('events-pipeline') -+ dashboard.time.withFrom(config.dashboards.timeRange.from) -+ dashboard.time.withTo(config.dashboards.timeRange.to) -+ dashboard.withTimezone(config.dashboards.timezone) -+ dashboard.withRefresh(refresh) -+ dashboard.withEditable(true) -+ dashboard.graphTooltip.withSharedCrosshair() -// TODO: Add cluster template variable when multi-cluster support is implemented -+ dashboard.withVariables([ - g.dashboard.variable.datasource.new('datasource', config.dashboards.datasource.type) - + g.dashboard.variable.datasource.generalOptions.withLabel('Prometheus Datasource') - + g.dashboard.variable.datasource.withRegex(datasourceRegex), -]) -+ dashboard.withPanels(allPanels) +// Control Plane Events Pipeline Dashboard +// Generated using Grafonnet v11.4.0 +// To build: jsonnet -J vendor dashboards/events-pipeline.jsonnet > ../config/components/observability/dashboards/generated/events-pipeline.json + +local g = import 'grafonnet-v11.4.0/main.libsonnet'; +local config = import '../config.libsonnet'; + +local dashboard = g.dashboard; +local panel = g.panel; +local stat = panel.stat; +local timeSeries = panel.timeSeries; +local row = panel.row; +local prometheus = g.query.prometheus; +local util = g.util; + +// Configuration +local datasource = config.dashboards.datasource.name; +local datasourceRegex = config.dashboards.datasource.regex; +local refresh = config.dashboards.refresh; + +// Reusable Queries +local queries = { + // Pipeline flow: [Activity API Server OR k8s-event-exporter] → NATS → Vector → ClickHouse + // + // Metrics support both production (apiserver) and dev (k8s-event-exporter) environments. + // Queries use `or` to combine both sources - whichever is running provides the data. + + // Event ingestion rate (from either apiserver or k8s-event-exporter) + eventsPublishedRate: '(sum(rate(activity_events_published_total[5m])) or vector(0)) + (sum(rate(event_exporter_events_published_total[5m])) or vector(0))', + eventsPublishedByNamespace: 'sum(rate(activity_events_published_total[5m])) by (namespace) or sum(rate(event_exporter_events_published_total[5m])) by (namespace)', + eventsPublishedByReason: 'sum(rate(activity_events_published_total[5m])) by (reason) or sum(rate(event_exporter_events_published_total[5m])) by (reason)', + + // Publish latency (from either apiserver or k8s-event-exporter) + publishLatencyP99: 'histogram_quantile(0.99, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.99, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', + publishLatencyP95: 'histogram_quantile(0.95, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.95, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', + publishLatencyP50: 'histogram_quantile(0.50, sum(rate(activity_events_publish_latency_seconds_bucket[5m])) by (le)) or histogram_quantile(0.50, sum(rate(event_exporter_publish_latency_seconds_bucket[5m])) by (le))', + + // Ingestion errors (from either source) + publishErrors: '(sum(rate(activity_events_publish_errors_total[5m])) or vector(0)) + (sum(rate(event_exporter_publish_errors_total[5m])) or vector(0))', + + // Ingestion source health (shows status from whichever source is active) + // In dev: k8s-event-exporter NATS connection status + // In prod: activity-apiserver events NATS connection status + ingestionSourceStatus: 'min(activity_events_nats_connection_status) or min(event_exporter_nats_connection_status{job="k8s-event-exporter"})', + exporterConnectionStatus: 'min(event_exporter_nats_connection_status{job="k8s-event-exporter"})', + informerSyncStatus: 'min(event_exporter_informer_synced{job="k8s-event-exporter"})', + + // NATS metrics (events stream) - same in all environments + natsEventsStreamMessages: 'sum(rate(nats_stream_total_messages{stream_name="EVENTS"}[5m]))', + natsEventsQueuePending: 'sum(nats_consumer_num_pending{consumer_name="clickhouse-ingest-events"}) or vector(0)', + natsEventsQueueAckPending: 'sum(nats_consumer_num_ack_pending{consumer_name="clickhouse-ingest-events"}) or vector(0)', + + // Vector metrics (events consumer → ClickHouse) - same in all environments + vectorNatsEventsReceived: 'sum(rate(vector_component_received_events_total{component_id="nats_events_consumer",namespace="activity-system"}[5m]))', + vectorClickhouseEventsSent: 'sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m]))', + vectorEventsErrors: 'sum(rate(vector_component_errors_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) or vector(0)', + vectorEventsBufferDepth: 'sum(vector_buffer_events{component_id="clickhouse_k8s_events",namespace="activity-system"}) or vector(0)', + + // ClickHouse metrics (k8s_events table) - same in all environments + clickhouseEventsWriteRate: 'activity:clickhouse_events_insert_rate:5m', + clickhouseEventsInsertLatency: 'activity:clickhouse_events_insert_latency', + clickhouseEventsTableRows: 'sum(chi_clickhouse_table_parts_rows{chi="activity-clickhouse",database="audit",table="k8s_events",active="1"})', + clickhouseEventsTableParts: 'avg(chi_clickhouse_table_parts{chi="activity-clickhouse",database="audit",table="k8s_events"})', + + // Activity API Server - EventQuery metrics (read path) + eventQueryRate: 'sum(rate(apiserver_request_total{job="activity-apiserver",resource="eventqueries"}[5m])) or vector(0)', + eventQueryLatencyP99: 'histogram_quantile(0.99, sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver",resource="eventqueries"}[5m])) by (le))', + eventQueryErrors: 'sum(rate(apiserver_request_total{job="activity-apiserver",resource="eventqueries",code=~"5.."}[5m])) or vector(0)', + + // Combined pipeline health + pipelineErrorRate: '(sum(rate(activity_events_publish_errors_total[5m])) or vector(0)) + (sum(rate(event_exporter_publish_errors_total[5m])) or vector(0)) + (sum(rate(vector_component_errors_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) or vector(0))', +}; + +// Build all panels +local allPanels = + // ============================================================================ + // Row 1: Critical Health Indicators + // ============================================================================ + [ + row.new('Critical Health Indicators') + + row.withCollapsed(false) + + row.gridPos.withH(1) + + row.gridPos.withW(24) + + row.gridPos.withX(0) + + row.gridPos.withY(0), + ] + + util.grid.makeGrid([ + stat.new('Events Ingested') + + stat.panelOptions.withDescription('Events/sec published to NATS (from apiserver or event-exporter)') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.eventsPublishedRate) + + prometheus.withLegendFormat('Events/sec'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'red', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'green', value: 1 }, + ]), + + stat.new('Events Written Rate') + + stat.panelOptions.withDescription('Events/sec written to ClickHouse k8s_events table') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('value') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.clickhouseEventsWriteRate) + + prometheus.withLegendFormat('Events/sec'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'red', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'green', value: 1 }, + ]), + + stat.new('Queue Backlog') + + stat.panelOptions.withDescription('Pending events in NATS queue (backpressure indicator)') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('short') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.natsEventsQueuePending) + + prometheus.withLegendFormat('Pending'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 100 }, + { color: 'red', value: 1000 }, + ]), + + stat.new('Error Rate') + + stat.panelOptions.withDescription('Combined errors across event exporter and Vector') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('ops') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.pipelineErrorRate) + + prometheus.withLegendFormat('Errors/sec'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'red', value: 1 }, + ]), + + stat.new('Ingestion Source') + + stat.panelOptions.withDescription('Health of event ingestion source (apiserver or event-exporter)') + + stat.options.withTextMode('value_and_name') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.ingestionSourceStatus) + + prometheus.withLegendFormat('Status'), + ]) + + stat.standardOptions.withMappings([ + { type: 'value', options: { '0': { text: 'Unhealthy', color: 'red' } } }, + { type: 'value', options: { '1': { text: 'Healthy', color: 'green' } } }, + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'red', value: null }, + { color: 'green', value: 1 }, + ]), + + stat.new('Vector Buffer') + + stat.panelOptions.withDescription('Events buffered in Vector before ClickHouse write') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.standardOptions.withUnit('short') + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.vectorEventsBufferDepth) + + prometheus.withLegendFormat('Buffered'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 100 }, + { color: 'red', value: 1000 }, + ]), + + stat.new('ClickHouse Insert Latency') + + stat.panelOptions.withDescription('Average time to write events to k8s_events table') + + stat.options.withGraphMode('area') + + stat.options.withColorMode('background') + + stat.standardOptions.withUnit('s') + + stat.options.reduceOptions.withCalcs(['lastNotNull']) + + stat.datasource.withType('prometheus') + + stat.datasource.withUid('$datasource') + + stat.queryOptions.withTargets([ + prometheus.new('$datasource', queries.clickhouseEventsInsertLatency) + + prometheus.withLegendFormat('Insert Latency'), + ]) + + stat.standardOptions.thresholds.withSteps([ + { color: 'green', value: null }, + { color: 'yellow', value: 0.1 }, + { color: 'orange', value: 0.5 }, + { color: 'red', value: 1.0 }, + ]), + ], panelWidth=3, panelHeight=4, startY=1) + + // ============================================================================ + // Row 2: Event Ingestion + // ============================================================================ + + [ + row.new('Event Ingestion') + + row.withCollapsed(false) + + row.gridPos.withH(1) + + row.gridPos.withW(24) + + row.gridPos.withX(0) + + row.gridPos.withY(5), + ] + + util.grid.makeGrid([ + timeSeries.new('Events by Namespace') + + timeSeries.panelOptions.withDescription('Top 10 namespaces generating events') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', 'topk(10, ' + queries.eventsPublishedByNamespace + ')') + + prometheus.withLegendFormat('{{namespace}}'), + ]), + + timeSeries.new('Events by Reason') + + timeSeries.panelOptions.withDescription('Common event reasons (Created, Scheduled, Pulling, etc.)') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.eventsPublishedByReason) + + prometheus.withLegendFormat('{{reason}}'), + ]), + + timeSeries.new('Publish Latency (p50/p95/p99)') + + timeSeries.panelOptions.withDescription('Latency distribution for publishing events to NATS') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('s') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.publishLatencyP99) + + prometheus.withLegendFormat('p99'), + prometheus.new('$datasource', queries.publishLatencyP95) + + prometheus.withLegendFormat('p95'), + prometheus.new('$datasource', queries.publishLatencyP50) + + prometheus.withLegendFormat('p50'), + ]), + ], panelWidth=8, panelHeight=7, startY=6) + + // ============================================================================ + // Row 3: Pipeline Flow + // ============================================================================ + + [ + row.new('Pipeline Flow') + + row.withCollapsed(false) + + row.gridPos.withH(1) + + row.gridPos.withW(24) + + row.gridPos.withX(0) + + row.gridPos.withY(13), + ] + + util.grid.makeGrid([ + timeSeries.new('Event Flow Through Stages') + + timeSeries.panelOptions.withDescription('Events/sec at each pipeline stage - should be roughly equal in steady state') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.eventsPublishedRate) + + prometheus.withLegendFormat('1. Published to NATS'), + prometheus.new('$datasource', queries.vectorNatsEventsReceived) + + prometheus.withLegendFormat('2. Consumed from NATS'), + prometheus.new('$datasource', queries.vectorClickhouseEventsSent) + + prometheus.withLegendFormat('3. Sent to ClickHouse'), + prometheus.new('$datasource', queries.clickhouseEventsWriteRate) + + prometheus.withLegendFormat('4. ClickHouse Writes'), + ]), + + timeSeries.new('Ingress vs Egress Comparison') + + timeSeries.panelOptions.withDescription('Pipeline input vs output - gap indicates bottleneck or loss') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(0) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.eventsPublishedRate) + + prometheus.withLegendFormat('Ingress (Published)'), + prometheus.new('$datasource', queries.clickhouseEventsWriteRate) + + prometheus.withLegendFormat('Egress (ClickHouse)'), + ]), + ], panelWidth=12, panelHeight=8, startY=14) + + // ============================================================================ + // Row 4: Performance + // ============================================================================ + + [ + row.new('Performance') + + row.withCollapsed(false) + + row.gridPos.withH(1) + + row.gridPos.withW(24) + + row.gridPos.withX(0) + + row.gridPos.withY(22), + ] + + util.grid.makeGrid([ + timeSeries.new('NATS Consumer Lag') + + timeSeries.panelOptions.withDescription('Pending messages for events consumer - indicates backpressure') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('short') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(20) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.natsEventsQueuePending) + + prometheus.withLegendFormat('Pending Messages'), + ]), + + timeSeries.new('Vector Buffer Depth') + + timeSeries.panelOptions.withDescription('Buffer depth indicates ClickHouse backpressure - high values mean slow writes') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('short') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.vectorEventsBufferDepth) + + prometheus.withLegendFormat('Buffered Events'), + ]), + + timeSeries.new('ClickHouse Insert Performance') + + timeSeries.panelOptions.withDescription('Events insert rate and latency - write path health for k8s_events table') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.clickhouseEventsWriteRate) + + prometheus.withLegendFormat('Events Inserted/sec'), + ]), + + timeSeries.new('Publish Errors Over Time') + + timeSeries.panelOptions.withDescription('Errors publishing events to NATS - should be ZERO in healthy state') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.publishErrors) + + prometheus.withLegendFormat('Publish Errors'), + ]), + ], panelWidth=6, panelHeight=7, startY=23) + + // ============================================================================ + // Row 5: Error Breakdown (collapsed) + // ============================================================================ + + [ + row.new('Error Breakdown') + + row.withCollapsed(true) + + row.gridPos.withH(1) + + row.gridPos.withW(24) + + row.gridPos.withX(0) + + row.gridPos.withY(30) + + row.withPanels( + util.grid.makeGrid([ + timeSeries.new('Errors by Component') + + timeSeries.panelOptions.withDescription('Breakdown of errors: Ingestion source vs Vector') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('right') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(30) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(1) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.fieldConfig.defaults.custom.stacking.withMode('normal') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', queries.publishErrors) + + prometheus.withLegendFormat('Ingestion Errors'), + prometheus.new('$datasource', queries.vectorEventsErrors) + + prometheus.withLegendFormat('Vector Errors'), + ]), + + timeSeries.new('Vector Component Errors') + + timeSeries.panelOptions.withDescription('Detailed Vector pipeline errors for events stream') + + timeSeries.options.legend.withDisplayMode('table') + + timeSeries.options.legend.withPlacement('bottom') + + timeSeries.options.legend.withShowLegend(true) + + timeSeries.options.legend.withCalcs(['mean', 'lastNotNull', 'max']) + + timeSeries.standardOptions.withUnit('ops') + + timeSeries.fieldConfig.defaults.custom.withFillOpacity(10) + + timeSeries.fieldConfig.defaults.custom.withLineWidth(2) + + timeSeries.fieldConfig.defaults.custom.withShowPoints('never') + + timeSeries.datasource.withType('prometheus') + + timeSeries.datasource.withUid('$datasource') + + timeSeries.queryOptions.withTargets([ + prometheus.new('$datasource', 'sum(rate(vector_component_errors_total{component_id=~".*events.*"}[5m])) by (component_id) or vector(0)') + + prometheus.withLegendFormat('{{component_id}}'), + ]), + ], panelWidth=12, panelHeight=8, startY=31) + ), + ]; + +// Dashboard +dashboard.new('Control Plane Events Pipeline') ++ dashboard.withDescription('End-to-end monitoring of K8s events: [Activity API Server|k8s-event-exporter] → NATS → Vector → ClickHouse. Works with both production (apiserver) and dev (event-exporter) environments.') ++ dashboard.withTags(['events', 'pipeline', 'activity', 'observability']) ++ dashboard.withUid('events-pipeline') ++ dashboard.time.withFrom(config.dashboards.timeRange.from) ++ dashboard.time.withTo(config.dashboards.timeRange.to) ++ dashboard.withTimezone(config.dashboards.timezone) ++ dashboard.withRefresh(refresh) ++ dashboard.withEditable(true) ++ dashboard.graphTooltip.withSharedCrosshair() +// TODO: Add cluster template variable when multi-cluster support is implemented ++ dashboard.withVariables([ + g.dashboard.variable.datasource.new('datasource', config.dashboards.datasource.type) + + g.dashboard.variable.datasource.generalOptions.withLabel('Prometheus Datasource') + + g.dashboard.variable.datasource.withRegex(datasourceRegex), +]) ++ dashboard.withPanels(allPanels) diff --git a/observability/mixin.libsonnet b/observability/mixin.libsonnet index c4b6411b..77a799f3 100644 --- a/observability/mixin.libsonnet +++ b/observability/mixin.libsonnet @@ -1,64 +1,64 @@ -// Activity Observability Mixin -// Combines Grafana dashboards, Prometheus alerts, and recording rules -// -// Usage: -// Alerts: jsonnet -J vendor -S mixin.libsonnet -e '(import "mixin.libsonnet").prometheusAlerts' -// Rules: jsonnet -J vendor -S mixin.libsonnet -e '(import "mixin.libsonnet").prometheusRules' -// Dashboards: See individual dashboard .jsonnet files -{ - // Import all alert definitions - _alerts:: { - sli: import 'alerts/activity-sli.libsonnet', - pipeline: import 'alerts/activity-pipeline.libsonnet', - }, - - // Import recording rules - _rules:: { - recordings: import 'rules/activity-recordings.libsonnet', - }, - - // Combine all alerts into a single PrometheusRule manifest - prometheusAlerts:: { - apiVersion: 'monitoring.coreos.com/v1', - kind: 'PrometheusRule', - metadata: { - name: 'activity-alerts', - namespace: 'activity-system', - labels: { - prometheus: 'activity', - 'app.kubernetes.io/part-of': 'activity', - monitoring: 'true', - }, - }, - spec: { - groups: - $._alerts.sli.prometheusAlerts.groups + - $._alerts.pipeline.prometheusAlerts.groups, - }, - }, - - // Combine all recording rules into a single PrometheusRule manifest - prometheusRules:: { - apiVersion: 'monitoring.coreos.com/v1', - kind: 'PrometheusRule', - metadata: { - name: 'activity-recordings', - namespace: 'activity-system', - labels: { - prometheus: 'activity', - 'app.kubernetes.io/part-of': 'activity', - monitoring: 'true', - }, - }, - spec: { - groups: $._rules.recordings.prometheusRules.groups, - }, - }, - - // Export configuration for documentation or debugging - _config:: { - name: 'activity-mixin', - version: '1.0.0', - description: 'Activity observability mixin: alerts, recording rules, and dashboards', - }, -} +// Activity Observability Mixin +// Combines Grafana dashboards, Prometheus alerts, and recording rules +// +// Usage: +// Alerts: jsonnet -J vendor -S mixin.libsonnet -e '(import "mixin.libsonnet").prometheusAlerts' +// Rules: jsonnet -J vendor -S mixin.libsonnet -e '(import "mixin.libsonnet").prometheusRules' +// Dashboards: See individual dashboard .jsonnet files +{ + // Import all alert definitions + _alerts:: { + sli: import 'alerts/activity-sli.libsonnet', + pipeline: import 'alerts/activity-pipeline.libsonnet', + }, + + // Import recording rules + _rules:: { + recordings: import 'rules/activity-recordings.libsonnet', + }, + + // Combine all alerts into a single PrometheusRule manifest + prometheusAlerts:: { + apiVersion: 'monitoring.coreos.com/v1', + kind: 'PrometheusRule', + metadata: { + name: 'activity-alerts', + namespace: 'activity-system', + labels: { + prometheus: 'activity', + 'app.kubernetes.io/part-of': 'activity', + monitoring: 'true', + }, + }, + spec: { + groups: + $._alerts.sli.prometheusAlerts.groups + + $._alerts.pipeline.prometheusAlerts.groups, + }, + }, + + // Combine all recording rules into a single PrometheusRule manifest + prometheusRules:: { + apiVersion: 'monitoring.coreos.com/v1', + kind: 'PrometheusRule', + metadata: { + name: 'activity-recordings', + namespace: 'activity-system', + labels: { + prometheus: 'activity', + 'app.kubernetes.io/part-of': 'activity', + monitoring: 'true', + }, + }, + spec: { + groups: $._rules.recordings.prometheusRules.groups, + }, + }, + + // Export configuration for documentation or debugging + _config:: { + name: 'activity-mixin', + version: '1.0.0', + description: 'Activity observability mixin: alerts, recording rules, and dashboards', + }, +} diff --git a/observability/rules/activity-recordings.libsonnet b/observability/rules/activity-recordings.libsonnet index 220f17e8..dab151eb 100644 --- a/observability/rules/activity-recordings.libsonnet +++ b/observability/rules/activity-recordings.libsonnet @@ -1,256 +1,256 @@ -// Activity Recording Rules -// Pre-computed metrics for dashboard performance and complex aggregations -{ - prometheusRules+:: { - groups+: [ - { - name: 'activity-recordings', - interval: '30s', - rules: [ - // ========================================================================= - // Request Rate Recordings - // ========================================================================= - - // Overall request rate by verb and resource - { - record: 'activity:request_rate:5m', - expr: ||| - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - by (verb, resource, code) - |||, - }, - - // Total request rate (for quick overview) - { - record: 'activity:request_rate_total:5m', - expr: ||| - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - |||, - }, - - // Error rate percentage (for SLI calculations) - { - record: 'activity:error_rate:5m', - expr: ||| - sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) - / - sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) - |||, - }, - - // ========================================================================= - // API Server Request Duration Recordings - // ========================================================================= - - // API server request latency percentiles (user-facing API performance) - { - record: 'activity:apiserver_request_duration:p50', - expr: ||| - histogram_quantile(0.50, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - |||, - }, - - { - record: 'activity:apiserver_request_duration:p95', - expr: ||| - histogram_quantile(0.95, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - |||, - }, - - { - record: 'activity:apiserver_request_duration:p99', - expr: ||| - histogram_quantile(0.99, - sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) - by (le) - ) - |||, - }, - - // ========================================================================= - // Query Performance Recordings - // ========================================================================= - - // Pre-compute query latency percentiles for dashboards - { - record: 'activity:query_duration:p50', - expr: ||| - histogram_quantile(0.50, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - |||, - }, - - { - record: 'activity:query_duration:p95', - expr: ||| - histogram_quantile(0.95, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - |||, - }, - - { - record: 'activity:query_duration:p99', - expr: ||| - histogram_quantile(0.99, - sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) - by (le) - ) - |||, - }, - - // Query rate by operation type - { - record: 'activity:query_rate:5m', - expr: ||| - sum(rate(activity_clickhouse_query_total[5m])) - by (status) - |||, - }, - - // ========================================================================= - // ClickHouse Performance Recordings - // ========================================================================= - - // ClickHouse query error rate - { - record: 'activity:clickhouse_error_rate:5m', - expr: ||| - sum(rate(activity_clickhouse_query_errors_total[5m])) - by (error_type) - |||, - }, - - // ========================================================================= - // Pipeline Throughput Recordings - // ========================================================================= - - // Vector throughput rate (events/sec from NATS audit consumer) - { - record: 'activity:vector_throughput:5m', - expr: ||| - sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) - |||, - }, - - // Vector to ClickHouse write rate (audit events) - { - record: 'activity:vector_writes:5m', - expr: ||| - sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) - |||, - }, - - // Pipeline lag (difference between intake and output) - { - record: 'activity:pipeline_lag:5m', - expr: ||| - sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) - - - sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) - |||, - }, - - // ========================================================================= - // NATS JetStream Recordings - // ========================================================================= - - // NATS consumer lag (pending messages for clickhouse-ingest consumer) - { - record: 'activity:nats_consumer_lag', - expr: ||| - nats_consumer_num_pending{stream_name="AUDIT_EVENTS",consumer_name="clickhouse-ingest"} - |||, - }, - - // NATS stream message rate (total messages in stream) - { - record: 'activity:nats_message_rate:5m', - expr: ||| - rate(nats_stream_total_messages{stream_name="AUDIT_EVENTS"}[5m]) - |||, - }, - - // ========================================================================= - // Resource Utilization Recordings - // ========================================================================= - - // CPU utilization by component - { - record: 'activity:cpu_utilization', - expr: ||| - sum(rate(container_cpu_usage_seconds_total{namespace="activity-system"}[5m])) - by (pod) - / - sum(container_spec_cpu_quota{namespace="activity-system"} / container_spec_cpu_period{namespace="activity-system"}) - by (pod) - |||, - }, - - // Memory utilization by component - { - record: 'activity:memory_utilization', - expr: ||| - sum(container_memory_working_set_bytes{namespace="activity-system"}) - by (pod) - / - sum(container_spec_memory_limit_bytes{namespace="activity-system"}) - by (pod) - |||, - }, - - // ========================================================================= - // Events Pipeline Recordings - // ========================================================================= - - // Events pipeline throughput (Vector events component) - { - record: 'activity:vector_writes_events:5m', - expr: ||| - sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) - |||, - }, - - // Events pipeline insert rate to ClickHouse - { - record: 'activity:clickhouse_events_insert_rate:5m', - expr: ||| - avg(rate(chi_clickhouse_table_parts_rows{chi="activity-clickhouse", database="audit", table="k8s_events", active="1"}[5m])) - |||, - }, - - // Events pipeline ClickHouse insert latency - // Note: chi_clickhouse_event_* metrics are cluster-wide (no table labels) - // so this shows overall ClickHouse insert latency, not per-table - // Uses clamp_min to avoid divide-by-zero when there are no insert queries - { - record: 'activity:clickhouse_events_insert_latency', - expr: ||| - sum(rate(chi_clickhouse_event_InsertQueryTimeMicroseconds{chi="activity-clickhouse"}[5m])) - / - clamp_min(sum(rate(chi_clickhouse_event_InsertQuery{chi="activity-clickhouse"}[5m])), 0.001) - / 1000000 - |||, - }, - - // Total event exporter throughput - { - record: 'activity:event_exporter_throughput:5m', - expr: ||| - sum(rate(event_exporter_events_published_total[5m])) - |||, - }, - ], - }, - ], - }, -} +// Activity Recording Rules +// Pre-computed metrics for dashboard performance and complex aggregations +{ + prometheusRules+:: { + groups+: [ + { + name: 'activity-recordings', + interval: '30s', + rules: [ + // ========================================================================= + // Request Rate Recordings + // ========================================================================= + + // Overall request rate by verb and resource + { + record: 'activity:request_rate:5m', + expr: ||| + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + by (verb, resource, code) + |||, + }, + + // Total request rate (for quick overview) + { + record: 'activity:request_rate_total:5m', + expr: ||| + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + |||, + }, + + // Error rate percentage (for SLI calculations) + { + record: 'activity:error_rate:5m', + expr: ||| + sum(rate(apiserver_request_total{job="activity-apiserver",code=~"5.."}[5m])) + / + sum(rate(apiserver_request_total{job="activity-apiserver"}[5m])) + |||, + }, + + // ========================================================================= + // API Server Request Duration Recordings + // ========================================================================= + + // API server request latency percentiles (user-facing API performance) + { + record: 'activity:apiserver_request_duration:p50', + expr: ||| + histogram_quantile(0.50, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + |||, + }, + + { + record: 'activity:apiserver_request_duration:p95', + expr: ||| + histogram_quantile(0.95, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + |||, + }, + + { + record: 'activity:apiserver_request_duration:p99', + expr: ||| + histogram_quantile(0.99, + sum(rate(apiserver_request_duration_seconds_bucket{job="activity-apiserver"}[5m])) + by (le) + ) + |||, + }, + + // ========================================================================= + // Query Performance Recordings + // ========================================================================= + + // Pre-compute query latency percentiles for dashboards + { + record: 'activity:query_duration:p50', + expr: ||| + histogram_quantile(0.50, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + |||, + }, + + { + record: 'activity:query_duration:p95', + expr: ||| + histogram_quantile(0.95, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + |||, + }, + + { + record: 'activity:query_duration:p99', + expr: ||| + histogram_quantile(0.99, + sum(rate(activity_clickhouse_query_duration_seconds_bucket{operation="total"}[5m])) + by (le) + ) + |||, + }, + + // Query rate by operation type + { + record: 'activity:query_rate:5m', + expr: ||| + sum(rate(activity_clickhouse_query_total[5m])) + by (status) + |||, + }, + + // ========================================================================= + // ClickHouse Performance Recordings + // ========================================================================= + + // ClickHouse query error rate + { + record: 'activity:clickhouse_error_rate:5m', + expr: ||| + sum(rate(activity_clickhouse_query_errors_total[5m])) + by (error_type) + |||, + }, + + // ========================================================================= + // Pipeline Throughput Recordings + // ========================================================================= + + // Vector throughput rate (events/sec from NATS audit consumer) + { + record: 'activity:vector_throughput:5m', + expr: ||| + sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) + |||, + }, + + // Vector to ClickHouse write rate (audit events) + { + record: 'activity:vector_writes:5m', + expr: ||| + sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) + |||, + }, + + // Pipeline lag (difference between intake and output) + { + record: 'activity:pipeline_lag:5m', + expr: ||| + sum(rate(vector_component_received_events_total{component_id="nats_audit_consumer",namespace="activity-system"}[5m])) + - + sum(rate(vector_component_sent_events_total{component_id="clickhouse_audit_events",namespace="activity-system"}[5m])) + |||, + }, + + // ========================================================================= + // NATS JetStream Recordings + // ========================================================================= + + // NATS consumer lag (pending messages for clickhouse-ingest consumer) + { + record: 'activity:nats_consumer_lag', + expr: ||| + nats_consumer_num_pending{stream_name="AUDIT_EVENTS",consumer_name="clickhouse-ingest"} + |||, + }, + + // NATS stream message rate (total messages in stream) + { + record: 'activity:nats_message_rate:5m', + expr: ||| + rate(nats_stream_total_messages{stream_name="AUDIT_EVENTS"}[5m]) + |||, + }, + + // ========================================================================= + // Resource Utilization Recordings + // ========================================================================= + + // CPU utilization by component + { + record: 'activity:cpu_utilization', + expr: ||| + sum(rate(container_cpu_usage_seconds_total{namespace="activity-system"}[5m])) + by (pod) + / + sum(container_spec_cpu_quota{namespace="activity-system"} / container_spec_cpu_period{namespace="activity-system"}) + by (pod) + |||, + }, + + // Memory utilization by component + { + record: 'activity:memory_utilization', + expr: ||| + sum(container_memory_working_set_bytes{namespace="activity-system"}) + by (pod) + / + sum(container_spec_memory_limit_bytes{namespace="activity-system"}) + by (pod) + |||, + }, + + // ========================================================================= + // Events Pipeline Recordings + // ========================================================================= + + // Events pipeline throughput (Vector events component) + { + record: 'activity:vector_writes_events:5m', + expr: ||| + sum(rate(vector_component_sent_events_total{component_id="clickhouse_k8s_events",namespace="activity-system"}[5m])) + |||, + }, + + // Events pipeline insert rate to ClickHouse + { + record: 'activity:clickhouse_events_insert_rate:5m', + expr: ||| + avg(rate(chi_clickhouse_table_parts_rows{chi="activity-clickhouse", database="audit", table="k8s_events", active="1"}[5m])) + |||, + }, + + // Events pipeline ClickHouse insert latency + // Note: chi_clickhouse_event_* metrics are cluster-wide (no table labels) + // so this shows overall ClickHouse insert latency, not per-table + // Uses clamp_min to avoid divide-by-zero when there are no insert queries + { + record: 'activity:clickhouse_events_insert_latency', + expr: ||| + sum(rate(chi_clickhouse_event_InsertQueryTimeMicroseconds{chi="activity-clickhouse"}[5m])) + / + clamp_min(sum(rate(chi_clickhouse_event_InsertQuery{chi="activity-clickhouse"}[5m])), 0.001) + / 1000000 + |||, + }, + + // Total event exporter throughput + { + record: 'activity:event_exporter_throughput:5m', + expr: ||| + sum(rate(event_exporter_events_published_total[5m])) + |||, + }, + ], + }, + ], + }, +} diff --git a/pkg/apis/activity/v1alpha1/types_activity.go b/pkg/apis/activity/v1alpha1/types_activity.go index df15deed..bfbb05da 100644 --- a/pkg/apis/activity/v1alpha1/types_activity.go +++ b/pkg/apis/activity/v1alpha1/types_activity.go @@ -1,241 +1,241 @@ -// +k8s:openapi-gen=true -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +genclient -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// Activity is a human-readable summary of something that happened in your cluster. -// Think of it as the "what changed and who did it" record that powers activity feeds, -// audit trails, and change history views. -// -// Activities are created automatically from audit logs and Kubernetes events based on -// your ActivityPolicy rules. They're read-only - you query them, not create them. -// -// # Accessing Activities -// -// There are three ways to get activity data, depending on what you need: -// -// | What you need | API to use | Notes | -// | --- | --- | --- | -// | Live feed | GET /activities?watch=true | Streams new activities as they happen. List only returns the last hour. | -// | Search history | POST /activityqueries | Query any time range with filters, search, and pagination. | -// | Filter options | POST /activityfacetqueries | Get values for dropdowns (e.g., "which actors have activities?"). | -// -// # Quick Examples -// -// Watch for new activities: -// -// kubectl get activities --watch -// -// List recent human-initiated changes: -// -// kubectl get activities --field-selector spec.changeSource=human -// -// For historical queries or advanced filtering, use ActivityQuery instead. -type Activity struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ActivitySpec `json:"spec"` -} - -// ActivitySpec contains the translated activity details. -type ActivitySpec struct { - // Summary is the human-readable description of what happened. - // Generated from ActivityPolicy templates. - // - // Example: "alice created HTTP proxy api-gateway" - // - // +required - Summary string `json:"summary"` - - // ChangeSource indicates who initiated the change. - // Used to filter human actions from system reconciliation noise. - // - // Values: - // - "human": User action via kubectl, API, or UI - // - "system": Controller reconciliation, operator actions, scheduled jobs - // - // +required - ChangeSource string `json:"changeSource"` - - // Actor identifies who performed the action. - // - // +required - Actor ActivityActor `json:"actor"` - - // Resource identifies the Kubernetes resource that was affected. - // - // +required - Resource ActivityResource `json:"resource"` - - // Links contains clickable references found in the summary. - // The portal uses these to make resource names in the summary clickable. - // - // +optional - // +listType=atomic - Links []ActivityLink `json:"links,omitempty"` - - // Tenant identifies the scope for multi-tenant isolation. - // - // +required - Tenant ActivityTenant `json:"tenant"` - - // Changes contains field-level changes for update/patch operations. - // Shows old and new values for modified fields. - // - // NOTE: This field may be empty in the initial implementation. - // Populating old values requires resource history lookups. - // - // +optional - // +listType=atomic - Changes []ActivityChange `json:"changes,omitempty"` - - // Origin identifies the source record for correlation. - // - // +required - Origin ActivityOrigin `json:"origin"` -} - -// ActivityActor identifies who performed an action. -type ActivityActor struct { - // Type indicates the actor category. - // Values: "user", "serviceaccount", "controller" - // - // +required - Type string `json:"type"` - - // Name is the display name for the actor. - // For users, this is typically the email address. - // For service accounts, this is the full name (e.g., "system:serviceaccount:default:my-sa"). - // For controllers, this is the controller name. - // - // +required - Name string `json:"name"` - - // UID is the unique identifier for the actor. - // Stable across username changes. - // - // +optional - UID string `json:"uid,omitempty"` - - // Email is the actor's email address. - // Only populated for user actors when available. - // - // +optional - Email string `json:"email,omitempty"` -} - -// ActivityResource identifies the Kubernetes resource affected by an activity. -type ActivityResource struct { - // APIGroup is the API group of the resource. - // Empty string for core API group. - // - // +optional - APIGroup string `json:"apiGroup,omitempty"` - - // APIVersion is the API version of the resource. - // - // +required - APIVersion string `json:"apiVersion"` - - // Kind is the kind of the resource. - // - // +required - Kind string `json:"kind"` - - // Name is the name of the resource. - // - // +required - Name string `json:"name"` - - // Namespace is the namespace of the resource. - // Empty for cluster-scoped resources. - // - // +optional - Namespace string `json:"namespace,omitempty"` - - // UID is the unique identifier of the resource. - // - // +optional - UID string `json:"uid,omitempty"` -} - -// ActivityLink represents a clickable reference in an activity summary. -type ActivityLink struct { - // Marker is the text substring in the summary that should be linked. - // The portal scans the summary for this marker and makes it clickable. - // - // Example: "HTTP proxy api-gateway" - // - // +required - Marker string `json:"marker"` - - // Resource identifies what the marker links to. - // - // +required - Resource ActivityResource `json:"resource"` -} - -// ActivityTenant identifies the scope for multi-tenant isolation. -type ActivityTenant struct { - // Type is the scope level. - // Values: "global", "organization", "project", "user" - // - // +required - Type string `json:"type"` - - // Name is the tenant identifier within the scope type. - // - // +required - Name string `json:"name"` -} - -// ActivityChange represents a field-level change in an update/patch operation. -type ActivityChange struct { - // Field is the path to the changed field (e.g., "spec.virtualhost.fqdn"). - // - // +required - Field string `json:"field"` - - // Old is the previous value. May be empty for new fields. - // - // +optional - Old string `json:"old,omitempty"` - - // New is the new value. May be empty for deleted fields. - // - // +optional - New string `json:"new,omitempty"` -} - -// ActivityOrigin identifies the source record for an activity. -type ActivityOrigin struct { - // Type indicates the source type. - // Values: "audit" (from audit logs), "event" (from Kubernetes events) - // - // +required - Type string `json:"type"` - - // ID is the correlation ID to the source record. - // For audit: the auditID from the audit log entry. - // For event: the metadata.uid of the Kubernetes Event. - // - // +required - ID string `json:"id"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// ActivityList contains a list of Activity resources. -type ActivityList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []Activity `json:"items"` -} - +// +k8s:openapi-gen=true +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// Activity is a human-readable summary of something that happened in your cluster. +// Think of it as the "what changed and who did it" record that powers activity feeds, +// audit trails, and change history views. +// +// Activities are created automatically from audit logs and Kubernetes events based on +// your ActivityPolicy rules. They're read-only - you query them, not create them. +// +// # Accessing Activities +// +// There are three ways to get activity data, depending on what you need: +// +// | What you need | API to use | Notes | +// | --- | --- | --- | +// | Live feed | GET /activities?watch=true | Streams new activities as they happen. List only returns the last hour. | +// | Search history | POST /activityqueries | Query any time range with filters, search, and pagination. | +// | Filter options | POST /activityfacetqueries | Get values for dropdowns (e.g., "which actors have activities?"). | +// +// # Quick Examples +// +// Watch for new activities: +// +// kubectl get activities --watch +// +// List recent human-initiated changes: +// +// kubectl get activities --field-selector spec.changeSource=human +// +// For historical queries or advanced filtering, use ActivityQuery instead. +type Activity struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ActivitySpec `json:"spec"` +} + +// ActivitySpec contains the translated activity details. +type ActivitySpec struct { + // Summary is the human-readable description of what happened. + // Generated from ActivityPolicy templates. + // + // Example: "alice created HTTP proxy api-gateway" + // + // +required + Summary string `json:"summary"` + + // ChangeSource indicates who initiated the change. + // Used to filter human actions from system reconciliation noise. + // + // Values: + // - "human": User action via kubectl, API, or UI + // - "system": Controller reconciliation, operator actions, scheduled jobs + // + // +required + ChangeSource string `json:"changeSource"` + + // Actor identifies who performed the action. + // + // +required + Actor ActivityActor `json:"actor"` + + // Resource identifies the Kubernetes resource that was affected. + // + // +required + Resource ActivityResource `json:"resource"` + + // Links contains clickable references found in the summary. + // The portal uses these to make resource names in the summary clickable. + // + // +optional + // +listType=atomic + Links []ActivityLink `json:"links,omitempty"` + + // Tenant identifies the scope for multi-tenant isolation. + // + // +required + Tenant ActivityTenant `json:"tenant"` + + // Changes contains field-level changes for update/patch operations. + // Shows old and new values for modified fields. + // + // NOTE: This field may be empty in the initial implementation. + // Populating old values requires resource history lookups. + // + // +optional + // +listType=atomic + Changes []ActivityChange `json:"changes,omitempty"` + + // Origin identifies the source record for correlation. + // + // +required + Origin ActivityOrigin `json:"origin"` +} + +// ActivityActor identifies who performed an action. +type ActivityActor struct { + // Type indicates the actor category. + // Values: "user", "serviceaccount", "controller" + // + // +required + Type string `json:"type"` + + // Name is the display name for the actor. + // For users, this is typically the email address. + // For service accounts, this is the full name (e.g., "system:serviceaccount:default:my-sa"). + // For controllers, this is the controller name. + // + // +required + Name string `json:"name"` + + // UID is the unique identifier for the actor. + // Stable across username changes. + // + // +optional + UID string `json:"uid,omitempty"` + + // Email is the actor's email address. + // Only populated for user actors when available. + // + // +optional + Email string `json:"email,omitempty"` +} + +// ActivityResource identifies the Kubernetes resource affected by an activity. +type ActivityResource struct { + // APIGroup is the API group of the resource. + // Empty string for core API group. + // + // +optional + APIGroup string `json:"apiGroup,omitempty"` + + // APIVersion is the API version of the resource. + // + // +required + APIVersion string `json:"apiVersion"` + + // Kind is the kind of the resource. + // + // +required + Kind string `json:"kind"` + + // Name is the name of the resource. + // + // +required + Name string `json:"name"` + + // Namespace is the namespace of the resource. + // Empty for cluster-scoped resources. + // + // +optional + Namespace string `json:"namespace,omitempty"` + + // UID is the unique identifier of the resource. + // + // +optional + UID string `json:"uid,omitempty"` +} + +// ActivityLink represents a clickable reference in an activity summary. +type ActivityLink struct { + // Marker is the text substring in the summary that should be linked. + // The portal scans the summary for this marker and makes it clickable. + // + // Example: "HTTP proxy api-gateway" + // + // +required + Marker string `json:"marker"` + + // Resource identifies what the marker links to. + // + // +required + Resource ActivityResource `json:"resource"` +} + +// ActivityTenant identifies the scope for multi-tenant isolation. +type ActivityTenant struct { + // Type is the scope level. + // Values: "global", "organization", "project", "user" + // + // +required + Type string `json:"type"` + + // Name is the tenant identifier within the scope type. + // + // +required + Name string `json:"name"` +} + +// ActivityChange represents a field-level change in an update/patch operation. +type ActivityChange struct { + // Field is the path to the changed field (e.g., "spec.virtualhost.fqdn"). + // + // +required + Field string `json:"field"` + + // Old is the previous value. May be empty for new fields. + // + // +optional + Old string `json:"old,omitempty"` + + // New is the new value. May be empty for deleted fields. + // + // +optional + New string `json:"new,omitempty"` +} + +// ActivityOrigin identifies the source record for an activity. +type ActivityOrigin struct { + // Type indicates the source type. + // Values: "audit" (from audit logs), "event" (from Kubernetes events) + // + // +required + Type string `json:"type"` + + // ID is the correlation ID to the source record. + // For audit: the auditID from the audit log entry. + // For event: the metadata.uid of the Kubernetes Event. + // + // +required + ID string `json:"id"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ActivityList contains a list of Activity resources. +type ActivityList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Activity `json:"items"` +} + diff --git a/pkg/apis/activity/v1alpha1/types_activitypolicy.go b/pkg/apis/activity/v1alpha1/types_activitypolicy.go index 2aee6ed2..dd9eb0e1 100644 --- a/pkg/apis/activity/v1alpha1/types_activitypolicy.go +++ b/pkg/apis/activity/v1alpha1/types_activitypolicy.go @@ -1,151 +1,151 @@ -// +k8s:openapi-gen=true -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +genclient -// +genclient:nonNamespaced -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// ActivityPolicy defines translation rules for a specific resource type. Service providers -// create one ActivityPolicy per resource kind to customize activity descriptions without -// modifying the Activity Processor. -// -// Example: -// -// apiVersion: activity.miloapis.com/v1alpha1 -// kind: ActivityPolicy -// metadata: -// name: networking-httpproxy -// spec: -// resource: -// apiGroup: networking.datumapis.com -// kind: HTTPProxy -// auditRules: -// - match: "audit.verb == 'create'" -// summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" -// eventRules: -// - match: "event.reason == 'Programmed'" -// summary: "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" -type ActivityPolicy struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec ActivityPolicySpec `json:"spec"` - Status ActivityPolicyStatus `json:"status,omitempty"` -} - -// ActivityPolicyStatus represents the current state of an ActivityPolicy. -type ActivityPolicyStatus struct { - // Conditions represent the current state of the policy. - // The "Ready" condition indicates whether all rules compile successfully. - // +optional - // +listType=map - // +listMapKey=type - Conditions []metav1.Condition `json:"conditions,omitempty"` - - // ObservedGeneration is the generation last processed by the controller. - // +optional - ObservedGeneration int64 `json:"observedGeneration,omitempty"` -} - -// ActivityPolicySpec defines the translation rules for a resource type. -type ActivityPolicySpec struct { - // Resource identifies the Kubernetes resource this policy applies to. - // One ActivityPolicy should exist per resource kind. - // - // +required - Resource ActivityPolicyResource `json:"resource"` - - // AuditRules define how to translate audit log entries into activity summaries. - // Rules are evaluated in order; the first matching rule wins. - // Available variables: audit (map with verb, objectRef, user, responseStatus, - // responseObject, requestObject), actor, actorRef, kind - // - // +optional - // +listType=map - // +listMapKey=name - AuditRules []ActivityPolicyRule `json:"auditRules,omitempty"` - - // EventRules define how to translate Kubernetes events into activity summaries. - // Rules are evaluated in order; the first matching rule wins. - // The `event` variable contains the full Kubernetes Event structure. - // Convenience variables available: actor - // - // +optional - // +listType=map - // +listMapKey=name - EventRules []ActivityPolicyRule `json:"eventRules,omitempty"` -} - -// ActivityPolicyResource identifies the target Kubernetes resource for a policy. -type ActivityPolicyResource struct { - // APIGroup is the API group of the target resource (e.g., "networking.datumapis.com"). - // Use an empty string for core API group resources. - // - // +required - APIGroup string `json:"apiGroup"` - - // Kind is the kind of the target resource (e.g., "HTTPProxy", "Network"). - // - // +required - Kind string `json:"kind"` -} - -// ActivityPolicyRule defines a single translation rule that matches input events -// and generates human-readable activity summaries. -type ActivityPolicyRule struct { - // Name is a unique identifier for this rule within the policy. - // Used for strategic merge patching and error reporting. - // - // +required - Name string `json:"name"` - - // Description is an optional human-readable description of what this rule does. - // - // +optional - Description string `json:"description,omitempty"` - - // Match is a CEL expression that determines if this rule applies to the input. - // For audit rules, use the `audit` variable (e.g., "audit.verb == 'create'", "audit.objectRef.namespace == 'default'"). - // For event rules, use the `event` variable (e.g., "event.reason == 'Programmed'"). - // - // Examples: - // "audit.verb == 'create'" - // "audit.verb in ['update', 'patch']" - // "event.reason.startsWith('Failed')" - // "true" (fallback rule that always matches) - // - // +required - Match string `json:"match"` - - // Summary is a CEL template for generating the activity summary. - // Use {{ }} delimiters to embed CEL expressions within strings. - // - // Available variables: - // - For audit rules: audit (map), actor, actorRef, kind - // Access audit fields via: audit.verb, audit.objectRef, audit.user, audit.responseStatus, audit.responseObject - // - For event rules: event, actor, actorRef - // - // Available functions: - // - link(displayText, resourceRef): Creates a clickable reference - // - // Examples: - // "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" - // "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" - // - // +required - Summary string `json:"summary"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// ActivityPolicyList is a list of ActivityPolicy objects -type ActivityPolicyList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - - Items []ActivityPolicy `json:"items"` -} +// +k8s:openapi-gen=true +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ActivityPolicy defines translation rules for a specific resource type. Service providers +// create one ActivityPolicy per resource kind to customize activity descriptions without +// modifying the Activity Processor. +// +// Example: +// +// apiVersion: activity.miloapis.com/v1alpha1 +// kind: ActivityPolicy +// metadata: +// name: networking-httpproxy +// spec: +// resource: +// apiGroup: networking.datumapis.com +// kind: HTTPProxy +// auditRules: +// - match: "audit.verb == 'create'" +// summary: "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" +// eventRules: +// - match: "event.reason == 'Programmed'" +// summary: "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" +type ActivityPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ActivityPolicySpec `json:"spec"` + Status ActivityPolicyStatus `json:"status,omitempty"` +} + +// ActivityPolicyStatus represents the current state of an ActivityPolicy. +type ActivityPolicyStatus struct { + // Conditions represent the current state of the policy. + // The "Ready" condition indicates whether all rules compile successfully. + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ObservedGeneration is the generation last processed by the controller. + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +// ActivityPolicySpec defines the translation rules for a resource type. +type ActivityPolicySpec struct { + // Resource identifies the Kubernetes resource this policy applies to. + // One ActivityPolicy should exist per resource kind. + // + // +required + Resource ActivityPolicyResource `json:"resource"` + + // AuditRules define how to translate audit log entries into activity summaries. + // Rules are evaluated in order; the first matching rule wins. + // Available variables: audit (map with verb, objectRef, user, responseStatus, + // responseObject, requestObject), actor, actorRef, kind + // + // +optional + // +listType=map + // +listMapKey=name + AuditRules []ActivityPolicyRule `json:"auditRules,omitempty"` + + // EventRules define how to translate Kubernetes events into activity summaries. + // Rules are evaluated in order; the first matching rule wins. + // The `event` variable contains the full Kubernetes Event structure. + // Convenience variables available: actor + // + // +optional + // +listType=map + // +listMapKey=name + EventRules []ActivityPolicyRule `json:"eventRules,omitempty"` +} + +// ActivityPolicyResource identifies the target Kubernetes resource for a policy. +type ActivityPolicyResource struct { + // APIGroup is the API group of the target resource (e.g., "networking.datumapis.com"). + // Use an empty string for core API group resources. + // + // +required + APIGroup string `json:"apiGroup"` + + // Kind is the kind of the target resource (e.g., "HTTPProxy", "Network"). + // + // +required + Kind string `json:"kind"` +} + +// ActivityPolicyRule defines a single translation rule that matches input events +// and generates human-readable activity summaries. +type ActivityPolicyRule struct { + // Name is a unique identifier for this rule within the policy. + // Used for strategic merge patching and error reporting. + // + // +required + Name string `json:"name"` + + // Description is an optional human-readable description of what this rule does. + // + // +optional + Description string `json:"description,omitempty"` + + // Match is a CEL expression that determines if this rule applies to the input. + // For audit rules, use the `audit` variable (e.g., "audit.verb == 'create'", "audit.objectRef.namespace == 'default'"). + // For event rules, use the `event` variable (e.g., "event.reason == 'Programmed'"). + // + // Examples: + // "audit.verb == 'create'" + // "audit.verb in ['update', 'patch']" + // "event.reason.startsWith('Failed')" + // "true" (fallback rule that always matches) + // + // +required + Match string `json:"match"` + + // Summary is a CEL template for generating the activity summary. + // Use {{ }} delimiters to embed CEL expressions within strings. + // + // Available variables: + // - For audit rules: audit (map), actor, actorRef, kind + // Access audit fields via: audit.verb, audit.objectRef, audit.user, audit.responseStatus, audit.responseObject + // - For event rules: event, actor, actorRef + // + // Available functions: + // - link(displayText, resourceRef): Creates a clickable reference + // + // Examples: + // "{{ actor }} created {{ link(kind + ' ' + audit.objectRef.name, audit.responseObject) }}" + // "{{ link(kind + ' ' + event.regarding.name, event.regarding) }} is now programmed" + // + // +required + Summary string `json:"summary"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// ActivityPolicyList is a list of ActivityPolicy objects +type ActivityPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ActivityPolicy `json:"items"` +} diff --git a/pkg/apis/activity/v1alpha1/types_eventfacetquery.go b/pkg/apis/activity/v1alpha1/types_eventfacetquery.go index ccc95e3b..b5c9b22f 100644 --- a/pkg/apis/activity/v1alpha1/types_eventfacetquery.go +++ b/pkg/apis/activity/v1alpha1/types_eventfacetquery.go @@ -1,72 +1,72 @@ -// +k8s:openapi-gen=true -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +genclient -// +genclient:nonNamespaced -// +genclient:onlyVerbs=create -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// EventFacetQuery is an ephemeral resource for getting distinct field values from Kubernetes Events. -// Use this to power autocomplete, filter dropdowns, and faceted search in UIs. -// -// The query returns counts for each distinct value, allowing you to show both -// available options and their frequency. -// -// Example: -// -// apiVersion: activity.miloapis.com/v1alpha1 -// kind: EventFacetQuery -// metadata: -// name: get-facets -// spec: -// timeRange: -// start: "now-7d" -// facets: -// - field: regarding.kind -// limit: 10 -// - field: reason -// - field: type -type EventFacetQuery struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec EventFacetQuerySpec `json:"spec"` - Status EventFacetQueryStatus `json:"status,omitempty"` -} - -// EventFacetQuerySpec defines which facets to retrieve from Kubernetes Events. -type EventFacetQuerySpec struct { - // TimeRange limits the time window for facet aggregation. - // If not specified, defaults to the last 7 days. - // - // +optional - TimeRange FacetTimeRange `json:"timeRange,omitempty"` - - // Facets specifies which fields to get distinct values for. - // Each facet returns the top N values with counts. - // - // Supported fields: - // - regarding.kind: Resource kinds (Pod, Deployment, etc.) - // - regarding.namespace: Namespaces of regarding objects - // - reason: Event reasons (Scheduled, Pulled, Created, etc.) - // - type: Event types (Normal, Warning) - // - source.component: Source components (kubelet, scheduler, etc.) - // - namespace: Event namespace - // - // +required - // +listType=atomic - Facets []FacetSpec `json:"facets"` -} - -// EventFacetQueryStatus contains the facet results. -type EventFacetQueryStatus struct { - // Facets contains the results for each requested facet. - // - // +optional - // +listType=atomic - Facets []FacetResult `json:"facets,omitempty"` -} +// +k8s:openapi-gen=true +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +genclient:onlyVerbs=create +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EventFacetQuery is an ephemeral resource for getting distinct field values from Kubernetes Events. +// Use this to power autocomplete, filter dropdowns, and faceted search in UIs. +// +// The query returns counts for each distinct value, allowing you to show both +// available options and their frequency. +// +// Example: +// +// apiVersion: activity.miloapis.com/v1alpha1 +// kind: EventFacetQuery +// metadata: +// name: get-facets +// spec: +// timeRange: +// start: "now-7d" +// facets: +// - field: regarding.kind +// limit: 10 +// - field: reason +// - field: type +type EventFacetQuery struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventFacetQuerySpec `json:"spec"` + Status EventFacetQueryStatus `json:"status,omitempty"` +} + +// EventFacetQuerySpec defines which facets to retrieve from Kubernetes Events. +type EventFacetQuerySpec struct { + // TimeRange limits the time window for facet aggregation. + // If not specified, defaults to the last 7 days. + // + // +optional + TimeRange FacetTimeRange `json:"timeRange,omitempty"` + + // Facets specifies which fields to get distinct values for. + // Each facet returns the top N values with counts. + // + // Supported fields: + // - regarding.kind: Resource kinds (Pod, Deployment, etc.) + // - regarding.namespace: Namespaces of regarding objects + // - reason: Event reasons (Scheduled, Pulled, Created, etc.) + // - type: Event types (Normal, Warning) + // - source.component: Source components (kubelet, scheduler, etc.) + // - namespace: Event namespace + // + // +required + // +listType=atomic + Facets []FacetSpec `json:"facets"` +} + +// EventFacetQueryStatus contains the facet results. +type EventFacetQueryStatus struct { + // Facets contains the results for each requested facet. + // + // +optional + // +listType=atomic + Facets []FacetResult `json:"facets,omitempty"` +} diff --git a/pkg/apis/activity/v1alpha1/types_eventquery.go b/pkg/apis/activity/v1alpha1/types_eventquery.go index a56fc122..d2e9aa0a 100644 --- a/pkg/apis/activity/v1alpha1/types_eventquery.go +++ b/pkg/apis/activity/v1alpha1/types_eventquery.go @@ -1,188 +1,188 @@ -// +k8s:openapi-gen=true -package v1alpha1 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// +genclient -// +genclient:nonNamespaced -// +genclient:onlyVerbs=create -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// EventQuery searches Kubernetes Events stored in ClickHouse. -// -// Unlike the native Events list (limited to 24 hours), EventQuery supports -// up to 60 days of history. Results are returned in the Status field, -// ordered newest-first. -// -// Quick Start: -// -// apiVersion: activity.miloapis.com/v1alpha1 -// kind: EventQuery -// metadata: -// name: recent-pod-failures -// spec: -// startTime: "now-7d" # last 7 days -// endTime: "now" -// namespace: "production" # optional: limit to namespace -// fieldSelector: "type=Warning" # optional: standard K8s field selector -// limit: 100 -// -// Time Formats: -// - Relative: "now-30d" (great for dashboards and recurring queries) -// - Absolute: "2024-01-01T00:00:00Z" (great for historical analysis) -type EventQuery struct { - metav1.TypeMeta `json:",inline"` - metav1.ObjectMeta `json:"metadata,omitempty"` - - Spec EventQuerySpec `json:"spec"` - Status EventQueryStatus `json:"status,omitempty"` -} - -// EventQuerySpec defines the search parameters. -// -// Required: startTime and endTime define your search window (max 60 days). -// Optional: namespace (limit to namespace), fieldSelector (standard K8s syntax), -// limit (page size, default 100), continue (pagination). -type EventQuerySpec struct { - // StartTime is the beginning of your search window (inclusive). - // - // Format Options: - // - Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w) - // Use for dashboards and recurring queries - they adjust automatically. - // - Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone) - // Use for historical analysis of specific time periods. - // - // Maximum lookback is 60 days from now. - // - // Examples: - // "now-7d" → 7 days ago - // "2024-06-15T14:30:00-05:00" → specific time with timezone offset - // - // +required - StartTime string `json:"startTime"` - - // EndTime is the end of your search window (exclusive). - // - // Uses the same formats as StartTime. Commonly "now" for the current moment. - // Must be greater than StartTime. - // - // Examples: - // "now" → current time - // "2024-01-02T00:00:00Z" → specific end point - // - // +required - EndTime string `json:"endTime"` - - // Namespace limits results to events from a specific namespace. - // Leave empty to query events across all namespaces. - // - // +optional - Namespace string `json:"namespace,omitempty"` - - // FieldSelector filters events using standard Kubernetes field selector syntax. - // - // Supported Fields: - // metadata.name - event name - // metadata.namespace - event namespace - // metadata.uid - event UID - // regarding.apiVersion - regarding resource API version - // regarding.kind - regarding resource kind (e.g., Pod, Deployment) - // regarding.namespace - regarding resource namespace - // regarding.name - regarding resource name - // regarding.uid - regarding resource UID - // regarding.fieldPath - regarding resource field path - // reason - event reason (e.g., FailedMount, Pulled) - // type - event type (Normal or Warning) - // source.component - reporting component - // source.host - reporting host - // reportingComponent - reporting component (alias for source.component) - // reportingInstance - reporting instance (alias for source.host) - // - // Operators: = (or ==), != - // Multiple conditions: comma-separated (all must match) - // - // Common Patterns: - // "type=Warning" - Warning events only - // "regarding.kind=Pod" - Events for pods - // "reason=FailedMount" - Mount failure events - // "regarding.name=my-pod,type=Warning" - Warnings for a specific pod - // - // +optional - FieldSelector string `json:"fieldSelector,omitempty"` - - // Limit sets the maximum number of results per page. - // Default: 100, Maximum: 1000. - // - // Use smaller values (10-50) for exploration, larger (500-1000) for data collection. - // Use continue to fetch additional pages. - // - // +optional - Limit int32 `json:"limit,omitempty"` - - // Continue is the pagination cursor for fetching additional pages. - // - // Leave empty for the first page. If status.continue is non-empty after a query, - // copy that value here in a new query with identical parameters to get the next page. - // Repeat until status.continue is empty. - // - // Important: Keep all other parameters (startTime, endTime, namespace, fieldSelector, - // limit) identical across paginated requests. The cursor is opaque - copy it exactly - // without modification. - // - // +optional - Continue string `json:"continue,omitempty"` -} - -// EventQueryStatus contains the query results and pagination state. -type EventQueryStatus struct { - // Results contains matching Kubernetes Events, sorted newest-first. - // - // Each event follows the eventsv1.Event format with fields like: - // regarding.{kind,name,namespace}, reason, note, type, - // eventTime, series.count, reportingController - // - // Empty results? Try broadening your field selector or time range. - // - // +listType=atomic - Results []EventRecord `json:"results,omitempty"` - - // Continue is the pagination cursor. - // Non-empty means more results are available - copy this to spec.continue for the next page. - // Empty means you have all results. - Continue string `json:"continue,omitempty"` - - // EffectiveStartTime is the actual start time used for this query (RFC3339 format). - // - // When you use relative times like "now-7d", this shows the exact timestamp that was - // calculated. Useful for understanding exactly what time range was queried, especially - // for auditing, debugging, or recreating queries with absolute timestamps. - // - // Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z, - // this will be "2025-12-10T12:00:00Z". - // - // +optional - EffectiveStartTime string `json:"effectiveStartTime,omitempty"` - - // EffectiveEndTime is the actual end time used for this query (RFC3339 format). - // - // When you use relative times like "now", this shows the exact timestamp that was - // calculated. Useful for understanding exactly what time range was queried. - // - // Example: If you query with endTime="now" at 2025-12-17T12:00:00Z, - // this will be "2025-12-17T12:00:00Z". - // - // +optional - EffectiveEndTime string `json:"effectiveEndTime,omitempty"` -} - -// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object - -// EventQueryList is required by the code generator but is not used directly. -// EventQuery is an ephemeral resource that only supports Create. -type EventQueryList struct { - metav1.TypeMeta `json:",inline"` - metav1.ListMeta `json:"metadata,omitempty"` - Items []EventQuery `json:"items"` -} +// +k8s:openapi-gen=true +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +genclient +// +genclient:nonNamespaced +// +genclient:onlyVerbs=create +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EventQuery searches Kubernetes Events stored in ClickHouse. +// +// Unlike the native Events list (limited to 24 hours), EventQuery supports +// up to 60 days of history. Results are returned in the Status field, +// ordered newest-first. +// +// Quick Start: +// +// apiVersion: activity.miloapis.com/v1alpha1 +// kind: EventQuery +// metadata: +// name: recent-pod-failures +// spec: +// startTime: "now-7d" # last 7 days +// endTime: "now" +// namespace: "production" # optional: limit to namespace +// fieldSelector: "type=Warning" # optional: standard K8s field selector +// limit: 100 +// +// Time Formats: +// - Relative: "now-30d" (great for dashboards and recurring queries) +// - Absolute: "2024-01-01T00:00:00Z" (great for historical analysis) +type EventQuery struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec EventQuerySpec `json:"spec"` + Status EventQueryStatus `json:"status,omitempty"` +} + +// EventQuerySpec defines the search parameters. +// +// Required: startTime and endTime define your search window (max 60 days). +// Optional: namespace (limit to namespace), fieldSelector (standard K8s syntax), +// limit (page size, default 100), continue (pagination). +type EventQuerySpec struct { + // StartTime is the beginning of your search window (inclusive). + // + // Format Options: + // - Relative: "now-30d", "now-2h", "now-30m" (units: s, m, h, d, w) + // Use for dashboards and recurring queries - they adjust automatically. + // - Absolute: "2024-01-01T00:00:00Z" (RFC3339 with timezone) + // Use for historical analysis of specific time periods. + // + // Maximum lookback is 60 days from now. + // + // Examples: + // "now-7d" → 7 days ago + // "2024-06-15T14:30:00-05:00" → specific time with timezone offset + // + // +required + StartTime string `json:"startTime"` + + // EndTime is the end of your search window (exclusive). + // + // Uses the same formats as StartTime. Commonly "now" for the current moment. + // Must be greater than StartTime. + // + // Examples: + // "now" → current time + // "2024-01-02T00:00:00Z" → specific end point + // + // +required + EndTime string `json:"endTime"` + + // Namespace limits results to events from a specific namespace. + // Leave empty to query events across all namespaces. + // + // +optional + Namespace string `json:"namespace,omitempty"` + + // FieldSelector filters events using standard Kubernetes field selector syntax. + // + // Supported Fields: + // metadata.name - event name + // metadata.namespace - event namespace + // metadata.uid - event UID + // regarding.apiVersion - regarding resource API version + // regarding.kind - regarding resource kind (e.g., Pod, Deployment) + // regarding.namespace - regarding resource namespace + // regarding.name - regarding resource name + // regarding.uid - regarding resource UID + // regarding.fieldPath - regarding resource field path + // reason - event reason (e.g., FailedMount, Pulled) + // type - event type (Normal or Warning) + // source.component - reporting component + // source.host - reporting host + // reportingComponent - reporting component (alias for source.component) + // reportingInstance - reporting instance (alias for source.host) + // + // Operators: = (or ==), != + // Multiple conditions: comma-separated (all must match) + // + // Common Patterns: + // "type=Warning" - Warning events only + // "regarding.kind=Pod" - Events for pods + // "reason=FailedMount" - Mount failure events + // "regarding.name=my-pod,type=Warning" - Warnings for a specific pod + // + // +optional + FieldSelector string `json:"fieldSelector,omitempty"` + + // Limit sets the maximum number of results per page. + // Default: 100, Maximum: 1000. + // + // Use smaller values (10-50) for exploration, larger (500-1000) for data collection. + // Use continue to fetch additional pages. + // + // +optional + Limit int32 `json:"limit,omitempty"` + + // Continue is the pagination cursor for fetching additional pages. + // + // Leave empty for the first page. If status.continue is non-empty after a query, + // copy that value here in a new query with identical parameters to get the next page. + // Repeat until status.continue is empty. + // + // Important: Keep all other parameters (startTime, endTime, namespace, fieldSelector, + // limit) identical across paginated requests. The cursor is opaque - copy it exactly + // without modification. + // + // +optional + Continue string `json:"continue,omitempty"` +} + +// EventQueryStatus contains the query results and pagination state. +type EventQueryStatus struct { + // Results contains matching Kubernetes Events, sorted newest-first. + // + // Each event follows the eventsv1.Event format with fields like: + // regarding.{kind,name,namespace}, reason, note, type, + // eventTime, series.count, reportingController + // + // Empty results? Try broadening your field selector or time range. + // + // +listType=atomic + Results []EventRecord `json:"results,omitempty"` + + // Continue is the pagination cursor. + // Non-empty means more results are available - copy this to spec.continue for the next page. + // Empty means you have all results. + Continue string `json:"continue,omitempty"` + + // EffectiveStartTime is the actual start time used for this query (RFC3339 format). + // + // When you use relative times like "now-7d", this shows the exact timestamp that was + // calculated. Useful for understanding exactly what time range was queried, especially + // for auditing, debugging, or recreating queries with absolute timestamps. + // + // Example: If you query with startTime="now-7d" at 2025-12-17T12:00:00Z, + // this will be "2025-12-10T12:00:00Z". + // + // +optional + EffectiveStartTime string `json:"effectiveStartTime,omitempty"` + + // EffectiveEndTime is the actual end time used for this query (RFC3339 format). + // + // When you use relative times like "now", this shows the exact timestamp that was + // calculated. Useful for understanding exactly what time range was queried. + // + // Example: If you query with endTime="now" at 2025-12-17T12:00:00Z, + // this will be "2025-12-17T12:00:00Z". + // + // +optional + EffectiveEndTime string `json:"effectiveEndTime,omitempty"` +} + +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object + +// EventQueryList is required by the code generator but is not used directly. +// EventQuery is an ephemeral resource that only supports Create. +type EventQueryList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EventQuery `json:"items"` +} diff --git a/pkg/cmd/history.go b/pkg/cmd/history.go index 146537e9..8c62ac38 100644 --- a/pkg/cmd/history.go +++ b/pkg/cmd/history.go @@ -1,813 +1,813 @@ -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/pmezard/go-difflib/difflib" - "github.com/spf13/cobra" - "golang.org/x/term" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - "k8s.io/cli-runtime/pkg/genericclioptions" - "k8s.io/cli-runtime/pkg/printers" - "k8s.io/kubectl/pkg/cmd/util" - - activityv1alpha1 "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" - clientset "go.miloapis.com/activity/pkg/client/clientset/versioned" -) - -// HistoryOptions contains the options for viewing resource history -type HistoryOptions struct { - Namespace string - Resource string - Name string - StartTime string - EndTime string - Limit int32 - ShowDiff bool - ContinueAfter string - AllPages bool - - PrintFlags *genericclioptions.PrintFlags - genericclioptions.IOStreams - Factory util.Factory -} - -// NewHistoryOptions creates a new HistoryOptions with default values -func NewHistoryOptions(f util.Factory, ioStreams genericclioptions.IOStreams) *HistoryOptions { - return &HistoryOptions{ - IOStreams: ioStreams, - Factory: f, - PrintFlags: genericclioptions.NewPrintFlags(""), - Limit: 100, - StartTime: "now-30d", - EndTime: "now", - } -} - -// NewHistoryCommand creates the history command -func NewHistoryCommand(f util.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { - o := NewHistoryOptions(f, ioStreams) - - cmd := &cobra.Command{ - Use: "history RESOURCE_TYPE NAME", - Short: "View the change history of a specific resource", - Long: `View the change history of a specific resource over time by querying audit logs. - -This command shows you the history of changes to a resource, displaying each modification -in chronological order. Use --diff to see what changed between consecutive versions. - -The command accepts the resource type and name as separate arguments: - - RESOURCE_TYPE: The type of resource (e.g., domains, dnsrecordsets, configmaps, secrets) - - NAME: The name of the specific resource instance - -Use the -n/--namespace flag for namespaced resources. - -Examples: - # View change history of a domain - activity history domains miloapis-com-0c8dxl -n default - - # View change history of a DNS record set - activity history dnsrecordsets dns-record-www-example-com -n production - - # View history with diff to see what changed - activity history configmaps app-config -n default --diff - - # View changes from the last 7 days - activity history secrets api-credentials -n default --start-time "now-7d" - - # Get all changes (fetch all pages) - activity history domains example-com -n default --all-pages - - # Use different output formats - activity history configmaps app-settings -n default -o json - activity history secrets db-password -n default -o yaml - -Output Modes: - Default (table): Shows a table with timestamp, verb, user, and status code - --diff: Shows unified diff between consecutive resource versions - -o json/yaml: Output raw audit events in JSON or YAML format -`, - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - if err := o.Complete(cmd, args); err != nil { - return err - } - if err := o.Validate(); err != nil { - return err - } - return o.Run(cmd.Context()) - }, - } - - // Add flags - cmd.Flags().StringVar(&o.StartTime, "start-time", o.StartTime, "Start time for the query (default: now-30d)") - cmd.Flags().StringVar(&o.EndTime, "end-time", o.EndTime, "End time for the query (default: now)") - cmd.Flags().Int32Var(&o.Limit, "limit", o.Limit, "Maximum number of results per page (1-1000)") - cmd.Flags().BoolVar(&o.ShowDiff, "diff", false, "Show diff between consecutive resource versions") - cmd.Flags().StringVar(&o.ContinueAfter, "continue-after", "", "Pagination cursor from previous query") - cmd.Flags().BoolVar(&o.AllPages, "all-pages", false, "Fetch all pages of results") - - // Add printer flags - o.PrintFlags.AddFlags(cmd) - - return cmd -} - -// Complete fills in missing options -func (o *HistoryOptions) Complete(cmd *cobra.Command, args []string) error { - // Set up IO streams if not already set - if o.Out == nil { - o.Out = os.Stdout - } - if o.ErrOut == nil { - o.ErrOut = os.Stderr - } - if o.In == nil { - o.In = os.Stdin - } - - // Parse resource type and name from arguments - if len(args) != 2 { - return fmt.Errorf("exactly two arguments are required: RESOURCE_TYPE NAME") - } - - o.Resource = args[0] - o.Name = args[1] - - // Get namespace from the factory's namespace flag if available - // The -n/--namespace flag is handled by the kubectl factory - if o.Factory != nil { - namespace, enforceNamespace, err := o.Factory.ToRawKubeConfigLoader().Namespace() - if err != nil { - return fmt.Errorf("failed to get namespace: %w", err) - } - // Only set namespace if it's explicitly set or enforced - if enforceNamespace || namespace != "" { - o.Namespace = namespace - } - } - - return nil -} - -// Validate checks that required options are set correctly -func (o *HistoryOptions) Validate() error { - if o.Resource == "" { - return fmt.Errorf("resource type is required") - } - if o.Name == "" { - return fmt.Errorf("resource name is required") - } - if o.StartTime == "" { - return fmt.Errorf("--start-time is required") - } - if o.EndTime == "" { - return fmt.Errorf("--end-time is required") - } - if o.Limit < 1 || o.Limit > 1000 { - return fmt.Errorf("--limit must be between 1 and 1000") - } - if o.AllPages && o.ContinueAfter != "" { - return fmt.Errorf("--all-pages and --continue-after are mutually exclusive") - } - - return nil -} - -// Run executes the history command -func (o *HistoryOptions) Run(ctx context.Context) error { - // Get REST config from factory - config, err := o.Factory.ToRESTConfig() - if err != nil { - return fmt.Errorf("failed to get kubeconfig: %w", err) - } - - // Create activity client - client, err := clientset.NewForConfig(config) - if err != nil { - return fmt.Errorf("failed to create activity client: %w", err) - } - - if o.AllPages { - return o.runAllPages(ctx, client) - } - - return o.runSinglePage(ctx, client) -} - -// runSinglePage executes a single query -func (o *HistoryOptions) runSinglePage(ctx context.Context, client *clientset.Clientset) error { - filter := o.buildFilter() - - query := &activityv1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "history-", - }, - Spec: activityv1alpha1.AuditLogQuerySpec{ - StartTime: o.StartTime, - EndTime: o.EndTime, - Filter: filter, - Limit: o.Limit, - Continue: o.ContinueAfter, - }, - } - - result, err := client.ActivityV1alpha1().AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("query failed: %w", err) - } - - return o.printResults(result) -} - -// runAllPages fetches all pages of results -func (o *HistoryOptions) runAllPages(ctx context.Context, client *clientset.Clientset) error { - var allEvents []auditv1.Event - continueAfter := "" - pageNum := 1 - filter := o.buildFilter() - - // Check if using custom output format - outputFormat := o.PrintFlags.OutputFormat - isCustomFormat := outputFormat != nil && *outputFormat != "" - - // For table or diff output, we need all events before processing - for { - query := &activityv1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "history-", - }, - Spec: activityv1alpha1.AuditLogQuerySpec{ - StartTime: o.StartTime, - EndTime: o.EndTime, - Filter: filter, - Limit: o.Limit, - Continue: continueAfter, - }, - } - - result, err := client.ActivityV1alpha1().AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return fmt.Errorf("query failed on page %d: %w", pageNum, err) - } - - allEvents = append(allEvents, result.Status.Results...) - - // Check if there are more pages - if result.Status.Continue == "" { - break - } - - continueAfter = result.Status.Continue - pageNum++ - } - - // Reverse events to show oldest first (since results come newest-first) - for i := 0; i < len(allEvents)/2; i++ { - j := len(allEvents) - i - 1 - allEvents[i], allEvents[j] = allEvents[j], allEvents[i] - } - - // Print results based on output format - if isCustomFormat { - printer, err := o.PrintFlags.ToPrinter() - if err != nil { - return fmt.Errorf("failed to create printer: %w", err) - } - return o.printEvents(allEvents, printer) - } else if o.ShowDiff { - return o.printDiff(allEvents) - } else { - return o.printTableAllEvents(allEvents) - } -} - -// buildFilter creates a CEL filter for the specified resource -func (o *HistoryOptions) buildFilter() string { - filters := []string{ - fmt.Sprintf("objectRef.resource == '%s'", o.Resource), - fmt.Sprintf("objectRef.name == '%s'", o.Name), - // Only include verbs that modify the resource - "verb in ['create', 'update', 'patch', 'delete']", - } - - if o.Namespace != "" { - filters = append(filters, fmt.Sprintf("objectRef.namespace == '%s'", o.Namespace)) - } - - return strings.Join(filters, " && ") -} - -// printResults outputs the query results in the specified format -func (o *HistoryOptions) printResults(result *activityv1alpha1.AuditLogQuery) error { - // Reverse events to show oldest first - events := result.Status.Results - for i := 0; i < len(events)/2; i++ { - j := len(events) - i - 1 - events[i], events[j] = events[j], events[i] - } - - // Check output format - outputFormat := o.PrintFlags.OutputFormat - if outputFormat != nil && *outputFormat != "" { - printer, err := o.PrintFlags.ToPrinter() - if err != nil { - return fmt.Errorf("failed to create printer: %w", err) - } - return o.printEvents(events, printer) - } - - if o.ShowDiff { - return o.printDiff(events) - } - - return o.printTable(events, result.Status.Continue) -} - -// printTable prints events as a formatted table -func (o *HistoryOptions) printTable(events []auditv1.Event, continueToken string) error { - table := o.eventsToTable(events) - tablePrinter := printers.NewTablePrinter(printers.PrintOptions{ - WithNamespace: false, - Wide: true, - }) - - if err := tablePrinter.PrintObj(table, o.Out); err != nil { - return err - } - - // Print pagination info - if continueToken != "" { - fmt.Fprintf(o.ErrOut, "\nMore results available. Use --continue-after '%s' to get the next page.\n", continueToken) - fmt.Fprintf(o.ErrOut, "Or use --all-pages to fetch all results automatically.\n") - } else { - fmt.Fprintf(o.ErrOut, "\nNo more results.\n") - } - - return nil -} - -// printTableAllEvents prints all events as a table (for --all-pages) -func (o *HistoryOptions) printTableAllEvents(events []auditv1.Event) error { - table := o.eventsToTable(events) - tablePrinter := printers.NewTablePrinter(printers.PrintOptions{ - WithNamespace: false, - Wide: true, - }) - - if err := tablePrinter.PrintObj(table, o.Out); err != nil { - return err - } - - fmt.Fprintf(o.ErrOut, "\nShowing %d events.\n", len(events)) - return nil -} - -// printDiff shows the diff between consecutive resource versions -func (o *HistoryOptions) printDiff(events []auditv1.Event) error { - if len(events) == 0 { - fmt.Fprintf(o.Out, "No changes found for this resource.\n") - return nil - } - - useColor := o.supportsColor() - var prevObject map[string]interface{} - - for i, event := range events { - timestamp := event.StageTimestamp.Time.Format("2006-01-02 15:04:05") - username := event.User.Username - verb := event.Verb - - // Get current object state - var currObject map[string]interface{} - if event.ResponseObject != nil && len(event.ResponseObject.Raw) > 0 { - if err := json.Unmarshal(event.ResponseObject.Raw, &currObject); err != nil { - fmt.Fprintf(o.ErrOut, "Warning: failed to parse response object for event %d: %v\n", i, err) - continue - } - } - - // Print pretty header for this change - o.printChangeHeader(i+1, timestamp, verb, username, event.ResponseStatus, useColor) - - // Show diff if we have both previous and current objects - if prevObject != nil && currObject != nil { - // Remove metadata noise for cleaner diffs - cleanPrev := o.cleanObjectForDiff(prevObject) - cleanCurr := o.cleanObjectForDiff(currObject) - - changes := o.summarizeChanges(cleanPrev, cleanCurr) - if changes != "" { - if useColor { - fmt.Fprintf(o.Out, "\n\033[1m📝 Changes:\033[0m %s\n", changes) - } else { - fmt.Fprintf(o.Out, "\nChanges: %s\n", changes) - } - } - - fmt.Fprintf(o.Out, "\n") - if err := o.printObjectDiff(cleanPrev, cleanCurr); err != nil { - fmt.Fprintf(o.ErrOut, "Warning: failed to generate diff: %v\n", err) - } - } else if currObject != nil { - // First change or create - show the full object state - cleanCurr := o.cleanObjectForDiff(currObject) - - if verb == "create" { - if useColor { - fmt.Fprintf(o.Out, "\n\033[32m✨ Created resource\033[0m\n\n") - } else { - fmt.Fprintf(o.Out, "\nCreated resource\n\n") - } - } else { - // First change we're seeing (update/patch but no previous state) - if useColor { - fmt.Fprintf(o.Out, "\n\033[33m📸 Initial state (oldest available change)\033[0m\n\n") - } else { - fmt.Fprintf(o.Out, "\nInitial state (oldest available change)\n\n") - } - } - - if err := o.printObjectPretty(cleanCurr, useColor); err != nil { - fmt.Fprintf(o.ErrOut, "Warning: failed to print object: %v\n", err) - } - } else if verb == "delete" && prevObject != nil { - if useColor { - fmt.Fprintf(o.Out, "\n\033[31m🗑️ Deleted resource\033[0m\n\n") - } else { - fmt.Fprintf(o.Out, "\nDeleted resource\n\n") - } - cleanPrev := o.cleanObjectForDiff(prevObject) - if err := o.printObjectPretty(cleanPrev, useColor); err != nil { - fmt.Fprintf(o.ErrOut, "Warning: failed to print object: %v\n", err) - } - } - - // Update previous object for next iteration - if currObject != nil { - prevObject = currObject - } - } - - if useColor { - fmt.Fprintf(o.ErrOut, "\n\033[2m──────────────────────────────────────────────────────────────\033[0m\n") - fmt.Fprintf(o.ErrOut, "\033[1mTotal:\033[0m %d changes\n", len(events)) - } else { - fmt.Fprintf(o.ErrOut, "\n──────────────────────────────────────────────────────────────\n") - fmt.Fprintf(o.ErrOut, "Total: %d changes\n", len(events)) - } - return nil -} - -// printChangeHeader prints a nicely formatted header for each change -func (o *HistoryOptions) printChangeHeader(changeNum int, timestamp, verb, username string, status *metav1.Status, useColor bool) { - if useColor { - // Box drawing characters for a nice border - fmt.Fprintf(o.Out, "\n\033[2m╭─────────────────────────────────────────────────────────────╮\033[0m\n") - - // Change number with emoji - var verbEmoji string - var verbColor string - switch verb { - case "create": - verbEmoji = "✨" - verbColor = "\033[32m" // green - case "update", "patch": - verbEmoji = "📝" - verbColor = "\033[33m" // yellow - case "delete": - verbEmoji = "🗑️" - verbColor = "\033[31m" // red - default: - verbEmoji = "•" - verbColor = "\033[0m" - } - - fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[1;36mChange #%-3d\033[0m %s %s%-8s\033[0m", changeNum, verbEmoji, verbColor, verb) - - // Status code with color - if status != nil { - statusColor := "\033[32m" // green for success - if status.Code >= 400 { - statusColor = "\033[31m" // red for errors - } - fmt.Fprintf(o.Out, " %s[%d]\033[0m", statusColor, status.Code) - } - fmt.Fprintf(o.Out, "\n") - - fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[90m🕐 %s\033[0m\n", timestamp) - fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[90m👤 %s\033[0m\n", username) - fmt.Fprintf(o.Out, "\033[2m╰─────────────────────────────────────────────────────────────╯\033[0m") - } else { - fmt.Fprintf(o.Out, "\n╭─────────────────────────────────────────────────────────────╮\n") - fmt.Fprintf(o.Out, "│ Change #%-3d %-8s", changeNum, verb) - if status != nil { - fmt.Fprintf(o.Out, " [%d]", status.Code) - } - fmt.Fprintf(o.Out, "\n") - fmt.Fprintf(o.Out, "│ %s\n", timestamp) - fmt.Fprintf(o.Out, "│ %s\n", username) - fmt.Fprintf(o.Out, "╰─────────────────────────────────────────────────────────────╯") - } -} - -// cleanObjectForDiff removes noisy fields from objects to make diffs cleaner -func (o *HistoryOptions) cleanObjectForDiff(obj map[string]interface{}) map[string]interface{} { - cleaned := make(map[string]interface{}) - - // Copy everything except metadata noise - for k, v := range obj { - // Skip these metadata fields that change on every update - if k == "metadata" { - if meta, ok := v.(map[string]interface{}); ok { - cleanedMeta := make(map[string]interface{}) - for mk, mv := range meta { - // Keep only useful metadata - switch mk { - case "name", "namespace", "labels", "annotations": - cleanedMeta[mk] = mv - } - } - if len(cleanedMeta) > 0 { - cleaned[k] = cleanedMeta - } - } - } else if k != "managedFields" && k != "resourceVersion" && k != "generation" && k != "uid" { - cleaned[k] = v - } - } - - return cleaned -} - -// summarizeChanges provides a one-line summary of what changed -func (o *HistoryOptions) summarizeChanges(prev, curr map[string]interface{}) string { - changes := []string{} - - // Track changed top-level fields - for k := range curr { - if k == "status" || k == "metadata" { - continue // These are too noisy - } - prevVal, _ := json.Marshal(prev[k]) - currVal, _ := json.Marshal(curr[k]) - if string(prevVal) != string(currVal) { - changes = append(changes, k) - } - } - - // Check for removed fields - for k := range prev { - if k == "status" || k == "metadata" { - continue - } - if _, exists := curr[k]; !exists { - changes = append(changes, k+" (removed)") - } - } - - if len(changes) == 0 { - return "metadata only" - } - - if len(changes) > 3 { - return fmt.Sprintf("%s and %d more fields", strings.Join(changes[:3], ", "), len(changes)-3) - } - - return strings.Join(changes, ", ") -} - -// printObjectPretty prints a JSON object with syntax highlighting -func (o *HistoryOptions) printObjectPretty(obj map[string]interface{}, useColor bool) error { - objJSON, err := json.MarshalIndent(obj, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal object: %w", err) - } - - if useColor { - // Simple JSON syntax highlighting - highlighted := o.highlightJSON(string(objJSON)) - fmt.Fprintln(o.Out, highlighted) - } else { - fmt.Fprintln(o.Out, string(objJSON)) - } - return nil -} - -// highlightJSON adds basic syntax highlighting to JSON -func (o *HistoryOptions) highlightJSON(jsonStr string) string { - const ( - colorKey = "\033[36m" // cyan for keys - colorString = "\033[33m" // yellow for string values - colorNumber = "\033[35m" // magenta for numbers - colorBool = "\033[32m" // green for booleans - colorNull = "\033[90m" // gray for null - colorReset = "\033[0m" - ) - - lines := strings.Split(jsonStr, "\n") - for i, line := range lines { - // Highlight keys (simplified - looks for "key":) - if strings.Contains(line, ":") { - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - key := parts[0] - value := parts[1] - - // Colorize the key - key = strings.ReplaceAll(key, `"`, colorKey+`"`+colorReset) - - // Colorize the value based on type - value = strings.TrimSpace(value) - if strings.HasPrefix(value, `"`) { - // String value - value = colorString + value + colorReset - } else if value == "true" || value == "false" { - // Boolean - value = colorBool + value + colorReset - } else if value == "null" || value == "null," { - // Null - value = colorNull + value + colorReset - } else if len(value) > 0 && (value[0] >= '0' && value[0] <= '9') { - // Number - value = colorNumber + value + colorReset - } - - lines[i] = key + ":" + " " + value - } - } - } - - return strings.Join(lines, "\n") -} - -// printObjectDiff prints a unified diff between two objects -func (o *HistoryOptions) printObjectDiff(prev, curr map[string]interface{}) error { - prevJSON, err := json.MarshalIndent(prev, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal previous object: %w", err) - } - - currJSON, err := json.MarshalIndent(curr, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal current object: %w", err) - } - - diff := difflib.UnifiedDiff{ - A: difflib.SplitLines(string(prevJSON)), - B: difflib.SplitLines(string(currJSON)), - FromFile: "Previous", - ToFile: "Current", - Context: 3, - } - - diffText, err := difflib.GetUnifiedDiffString(diff) - if err != nil { - return fmt.Errorf("failed to generate diff: %w", err) - } - - if diffText == "" { - fmt.Fprintf(o.Out, "(no changes detected)\n") - } else { - // Colorize the diff output if terminal supports it - colorizedDiff := o.colorizeDiff(diffText) - fmt.Fprint(o.Out, colorizedDiff) - } - - return nil -} - -// colorizeDiff adds ANSI color codes to diff output -func (o *HistoryOptions) colorizeDiff(diff string) string { - // Check if output is a terminal that supports color - if !o.supportsColor() { - return diff - } - - // ANSI color codes - const ( - colorReset = "\033[0m" - colorRed = "\033[31m" // for deletions (-) - colorGreen = "\033[32m" // for additions (+) - colorCyan = "\033[36m" // for file headers (@@ and ---) - colorBold = "\033[1m" // for header emphasis - ) - - lines := strings.Split(diff, "\n") - colorizedLines := make([]string, len(lines)) - - for i, line := range lines { - if len(line) == 0 { - colorizedLines[i] = line - continue - } - - switch { - case strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++"): - // File headers - colorizedLines[i] = colorBold + colorCyan + line + colorReset - case strings.HasPrefix(line, "@@"): - // Hunk headers - colorizedLines[i] = colorCyan + line + colorReset - case strings.HasPrefix(line, "-"): - // Deletions - colorizedLines[i] = colorRed + line + colorReset - case strings.HasPrefix(line, "+"): - // Additions - colorizedLines[i] = colorGreen + line + colorReset - default: - // Context lines - colorizedLines[i] = line - } - } - - return strings.Join(colorizedLines, "\n") -} - -// supportsColor checks if the output stream supports ANSI color codes -func (o *HistoryOptions) supportsColor() bool { - // Check if NO_COLOR environment variable is set (universal opt-out) - if os.Getenv("NO_COLOR") != "" { - return false - } - - // Check if output is a terminal (not redirected to a file) - if o.Out != os.Stdout { - return false - } - - // Check TERM environment variable - termEnv := os.Getenv("TERM") - if termEnv == "dumb" || termEnv == "" { - return false - } - - // Check if stdout is a terminal using term.IsTerminal - // os.Stdout.Fd() returns the file descriptor for stdout - return term.IsTerminal(int(os.Stdout.Fd())) -} - -// printObject prints a JSON object with indentation -func (o *HistoryOptions) printObject(obj map[string]interface{}) error { - objJSON, err := json.MarshalIndent(obj, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal object: %w", err) - } - fmt.Fprintln(o.Out, string(objJSON)) - return nil -} - -// printEvents prints audit events using the configured printer -func (o *HistoryOptions) printEvents(events []auditv1.Event, printer printers.ResourcePrinter) error { - eventList := &auditv1.EventList{ - TypeMeta: metav1.TypeMeta{ - Kind: "EventList", - APIVersion: "audit.k8s.io/v1", - }, - Items: events, - } - return printer.PrintObj(eventList, o.Out) -} - -// eventsToTable converts audit events to a Table object -func (o *HistoryOptions) eventsToTable(events []auditv1.Event) *metav1.Table { - return &metav1.Table{ - TypeMeta: metav1.TypeMeta{ - Kind: "Table", - APIVersion: "meta.k8s.io/v1", - }, - ColumnDefinitions: []metav1.TableColumnDefinition{ - {Name: "Timestamp", Type: "string", Description: "Time of the event"}, - {Name: "Verb", Type: "string", Description: "Action performed"}, - {Name: "User", Type: "string", Description: "User who performed the action"}, - {Name: "Status", Type: "string", Description: "HTTP status code"}, - }, - Rows: o.eventsToRows(events), - } -} - -// eventsToRows converts audit events to table rows -func (o *HistoryOptions) eventsToRows(events []auditv1.Event) []metav1.TableRow { - rows := make([]metav1.TableRow, 0, len(events)) - for i := range events { - timestamp := events[i].StageTimestamp.Time.Format("2006-01-02 15:04:05") - verb := events[i].Verb - username := events[i].User.Username - - status := "" - if events[i].ResponseStatus != nil { - status = fmt.Sprintf("%d", events[i].ResponseStatus.Code) - } - - row := metav1.TableRow{ - Cells: []interface{}{timestamp, verb, username, status}, - } - rows = append(rows, row) - } - return rows -} +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/pmezard/go-difflib/difflib" + "github.com/spf13/cobra" + "golang.org/x/term" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/kubectl/pkg/cmd/util" + + activityv1alpha1 "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" + clientset "go.miloapis.com/activity/pkg/client/clientset/versioned" +) + +// HistoryOptions contains the options for viewing resource history +type HistoryOptions struct { + Namespace string + Resource string + Name string + StartTime string + EndTime string + Limit int32 + ShowDiff bool + ContinueAfter string + AllPages bool + + PrintFlags *genericclioptions.PrintFlags + genericclioptions.IOStreams + Factory util.Factory +} + +// NewHistoryOptions creates a new HistoryOptions with default values +func NewHistoryOptions(f util.Factory, ioStreams genericclioptions.IOStreams) *HistoryOptions { + return &HistoryOptions{ + IOStreams: ioStreams, + Factory: f, + PrintFlags: genericclioptions.NewPrintFlags(""), + Limit: 100, + StartTime: "now-30d", + EndTime: "now", + } +} + +// NewHistoryCommand creates the history command +func NewHistoryCommand(f util.Factory, ioStreams genericclioptions.IOStreams) *cobra.Command { + o := NewHistoryOptions(f, ioStreams) + + cmd := &cobra.Command{ + Use: "history RESOURCE_TYPE NAME", + Short: "View the change history of a specific resource", + Long: `View the change history of a specific resource over time by querying audit logs. + +This command shows you the history of changes to a resource, displaying each modification +in chronological order. Use --diff to see what changed between consecutive versions. + +The command accepts the resource type and name as separate arguments: + - RESOURCE_TYPE: The type of resource (e.g., domains, dnsrecordsets, configmaps, secrets) + - NAME: The name of the specific resource instance + +Use the -n/--namespace flag for namespaced resources. + +Examples: + # View change history of a domain + activity history domains miloapis-com-0c8dxl -n default + + # View change history of a DNS record set + activity history dnsrecordsets dns-record-www-example-com -n production + + # View history with diff to see what changed + activity history configmaps app-config -n default --diff + + # View changes from the last 7 days + activity history secrets api-credentials -n default --start-time "now-7d" + + # Get all changes (fetch all pages) + activity history domains example-com -n default --all-pages + + # Use different output formats + activity history configmaps app-settings -n default -o json + activity history secrets db-password -n default -o yaml + +Output Modes: + Default (table): Shows a table with timestamp, verb, user, and status code + --diff: Shows unified diff between consecutive resource versions + -o json/yaml: Output raw audit events in JSON or YAML format +`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := o.Complete(cmd, args); err != nil { + return err + } + if err := o.Validate(); err != nil { + return err + } + return o.Run(cmd.Context()) + }, + } + + // Add flags + cmd.Flags().StringVar(&o.StartTime, "start-time", o.StartTime, "Start time for the query (default: now-30d)") + cmd.Flags().StringVar(&o.EndTime, "end-time", o.EndTime, "End time for the query (default: now)") + cmd.Flags().Int32Var(&o.Limit, "limit", o.Limit, "Maximum number of results per page (1-1000)") + cmd.Flags().BoolVar(&o.ShowDiff, "diff", false, "Show diff between consecutive resource versions") + cmd.Flags().StringVar(&o.ContinueAfter, "continue-after", "", "Pagination cursor from previous query") + cmd.Flags().BoolVar(&o.AllPages, "all-pages", false, "Fetch all pages of results") + + // Add printer flags + o.PrintFlags.AddFlags(cmd) + + return cmd +} + +// Complete fills in missing options +func (o *HistoryOptions) Complete(cmd *cobra.Command, args []string) error { + // Set up IO streams if not already set + if o.Out == nil { + o.Out = os.Stdout + } + if o.ErrOut == nil { + o.ErrOut = os.Stderr + } + if o.In == nil { + o.In = os.Stdin + } + + // Parse resource type and name from arguments + if len(args) != 2 { + return fmt.Errorf("exactly two arguments are required: RESOURCE_TYPE NAME") + } + + o.Resource = args[0] + o.Name = args[1] + + // Get namespace from the factory's namespace flag if available + // The -n/--namespace flag is handled by the kubectl factory + if o.Factory != nil { + namespace, enforceNamespace, err := o.Factory.ToRawKubeConfigLoader().Namespace() + if err != nil { + return fmt.Errorf("failed to get namespace: %w", err) + } + // Only set namespace if it's explicitly set or enforced + if enforceNamespace || namespace != "" { + o.Namespace = namespace + } + } + + return nil +} + +// Validate checks that required options are set correctly +func (o *HistoryOptions) Validate() error { + if o.Resource == "" { + return fmt.Errorf("resource type is required") + } + if o.Name == "" { + return fmt.Errorf("resource name is required") + } + if o.StartTime == "" { + return fmt.Errorf("--start-time is required") + } + if o.EndTime == "" { + return fmt.Errorf("--end-time is required") + } + if o.Limit < 1 || o.Limit > 1000 { + return fmt.Errorf("--limit must be between 1 and 1000") + } + if o.AllPages && o.ContinueAfter != "" { + return fmt.Errorf("--all-pages and --continue-after are mutually exclusive") + } + + return nil +} + +// Run executes the history command +func (o *HistoryOptions) Run(ctx context.Context) error { + // Get REST config from factory + config, err := o.Factory.ToRESTConfig() + if err != nil { + return fmt.Errorf("failed to get kubeconfig: %w", err) + } + + // Create activity client + client, err := clientset.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create activity client: %w", err) + } + + if o.AllPages { + return o.runAllPages(ctx, client) + } + + return o.runSinglePage(ctx, client) +} + +// runSinglePage executes a single query +func (o *HistoryOptions) runSinglePage(ctx context.Context, client *clientset.Clientset) error { + filter := o.buildFilter() + + query := &activityv1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "history-", + }, + Spec: activityv1alpha1.AuditLogQuerySpec{ + StartTime: o.StartTime, + EndTime: o.EndTime, + Filter: filter, + Limit: o.Limit, + Continue: o.ContinueAfter, + }, + } + + result, err := client.ActivityV1alpha1().AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("query failed: %w", err) + } + + return o.printResults(result) +} + +// runAllPages fetches all pages of results +func (o *HistoryOptions) runAllPages(ctx context.Context, client *clientset.Clientset) error { + var allEvents []auditv1.Event + continueAfter := "" + pageNum := 1 + filter := o.buildFilter() + + // Check if using custom output format + outputFormat := o.PrintFlags.OutputFormat + isCustomFormat := outputFormat != nil && *outputFormat != "" + + // For table or diff output, we need all events before processing + for { + query := &activityv1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "history-", + }, + Spec: activityv1alpha1.AuditLogQuerySpec{ + StartTime: o.StartTime, + EndTime: o.EndTime, + Filter: filter, + Limit: o.Limit, + Continue: continueAfter, + }, + } + + result, err := client.ActivityV1alpha1().AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("query failed on page %d: %w", pageNum, err) + } + + allEvents = append(allEvents, result.Status.Results...) + + // Check if there are more pages + if result.Status.Continue == "" { + break + } + + continueAfter = result.Status.Continue + pageNum++ + } + + // Reverse events to show oldest first (since results come newest-first) + for i := 0; i < len(allEvents)/2; i++ { + j := len(allEvents) - i - 1 + allEvents[i], allEvents[j] = allEvents[j], allEvents[i] + } + + // Print results based on output format + if isCustomFormat { + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return fmt.Errorf("failed to create printer: %w", err) + } + return o.printEvents(allEvents, printer) + } else if o.ShowDiff { + return o.printDiff(allEvents) + } else { + return o.printTableAllEvents(allEvents) + } +} + +// buildFilter creates a CEL filter for the specified resource +func (o *HistoryOptions) buildFilter() string { + filters := []string{ + fmt.Sprintf("objectRef.resource == '%s'", o.Resource), + fmt.Sprintf("objectRef.name == '%s'", o.Name), + // Only include verbs that modify the resource + "verb in ['create', 'update', 'patch', 'delete']", + } + + if o.Namespace != "" { + filters = append(filters, fmt.Sprintf("objectRef.namespace == '%s'", o.Namespace)) + } + + return strings.Join(filters, " && ") +} + +// printResults outputs the query results in the specified format +func (o *HistoryOptions) printResults(result *activityv1alpha1.AuditLogQuery) error { + // Reverse events to show oldest first + events := result.Status.Results + for i := 0; i < len(events)/2; i++ { + j := len(events) - i - 1 + events[i], events[j] = events[j], events[i] + } + + // Check output format + outputFormat := o.PrintFlags.OutputFormat + if outputFormat != nil && *outputFormat != "" { + printer, err := o.PrintFlags.ToPrinter() + if err != nil { + return fmt.Errorf("failed to create printer: %w", err) + } + return o.printEvents(events, printer) + } + + if o.ShowDiff { + return o.printDiff(events) + } + + return o.printTable(events, result.Status.Continue) +} + +// printTable prints events as a formatted table +func (o *HistoryOptions) printTable(events []auditv1.Event, continueToken string) error { + table := o.eventsToTable(events) + tablePrinter := printers.NewTablePrinter(printers.PrintOptions{ + WithNamespace: false, + Wide: true, + }) + + if err := tablePrinter.PrintObj(table, o.Out); err != nil { + return err + } + + // Print pagination info + if continueToken != "" { + fmt.Fprintf(o.ErrOut, "\nMore results available. Use --continue-after '%s' to get the next page.\n", continueToken) + fmt.Fprintf(o.ErrOut, "Or use --all-pages to fetch all results automatically.\n") + } else { + fmt.Fprintf(o.ErrOut, "\nNo more results.\n") + } + + return nil +} + +// printTableAllEvents prints all events as a table (for --all-pages) +func (o *HistoryOptions) printTableAllEvents(events []auditv1.Event) error { + table := o.eventsToTable(events) + tablePrinter := printers.NewTablePrinter(printers.PrintOptions{ + WithNamespace: false, + Wide: true, + }) + + if err := tablePrinter.PrintObj(table, o.Out); err != nil { + return err + } + + fmt.Fprintf(o.ErrOut, "\nShowing %d events.\n", len(events)) + return nil +} + +// printDiff shows the diff between consecutive resource versions +func (o *HistoryOptions) printDiff(events []auditv1.Event) error { + if len(events) == 0 { + fmt.Fprintf(o.Out, "No changes found for this resource.\n") + return nil + } + + useColor := o.supportsColor() + var prevObject map[string]interface{} + + for i, event := range events { + timestamp := event.StageTimestamp.Time.Format("2006-01-02 15:04:05") + username := event.User.Username + verb := event.Verb + + // Get current object state + var currObject map[string]interface{} + if event.ResponseObject != nil && len(event.ResponseObject.Raw) > 0 { + if err := json.Unmarshal(event.ResponseObject.Raw, &currObject); err != nil { + fmt.Fprintf(o.ErrOut, "Warning: failed to parse response object for event %d: %v\n", i, err) + continue + } + } + + // Print pretty header for this change + o.printChangeHeader(i+1, timestamp, verb, username, event.ResponseStatus, useColor) + + // Show diff if we have both previous and current objects + if prevObject != nil && currObject != nil { + // Remove metadata noise for cleaner diffs + cleanPrev := o.cleanObjectForDiff(prevObject) + cleanCurr := o.cleanObjectForDiff(currObject) + + changes := o.summarizeChanges(cleanPrev, cleanCurr) + if changes != "" { + if useColor { + fmt.Fprintf(o.Out, "\n\033[1m📝 Changes:\033[0m %s\n", changes) + } else { + fmt.Fprintf(o.Out, "\nChanges: %s\n", changes) + } + } + + fmt.Fprintf(o.Out, "\n") + if err := o.printObjectDiff(cleanPrev, cleanCurr); err != nil { + fmt.Fprintf(o.ErrOut, "Warning: failed to generate diff: %v\n", err) + } + } else if currObject != nil { + // First change or create - show the full object state + cleanCurr := o.cleanObjectForDiff(currObject) + + if verb == "create" { + if useColor { + fmt.Fprintf(o.Out, "\n\033[32m✨ Created resource\033[0m\n\n") + } else { + fmt.Fprintf(o.Out, "\nCreated resource\n\n") + } + } else { + // First change we're seeing (update/patch but no previous state) + if useColor { + fmt.Fprintf(o.Out, "\n\033[33m📸 Initial state (oldest available change)\033[0m\n\n") + } else { + fmt.Fprintf(o.Out, "\nInitial state (oldest available change)\n\n") + } + } + + if err := o.printObjectPretty(cleanCurr, useColor); err != nil { + fmt.Fprintf(o.ErrOut, "Warning: failed to print object: %v\n", err) + } + } else if verb == "delete" && prevObject != nil { + if useColor { + fmt.Fprintf(o.Out, "\n\033[31m🗑️ Deleted resource\033[0m\n\n") + } else { + fmt.Fprintf(o.Out, "\nDeleted resource\n\n") + } + cleanPrev := o.cleanObjectForDiff(prevObject) + if err := o.printObjectPretty(cleanPrev, useColor); err != nil { + fmt.Fprintf(o.ErrOut, "Warning: failed to print object: %v\n", err) + } + } + + // Update previous object for next iteration + if currObject != nil { + prevObject = currObject + } + } + + if useColor { + fmt.Fprintf(o.ErrOut, "\n\033[2m──────────────────────────────────────────────────────────────\033[0m\n") + fmt.Fprintf(o.ErrOut, "\033[1mTotal:\033[0m %d changes\n", len(events)) + } else { + fmt.Fprintf(o.ErrOut, "\n──────────────────────────────────────────────────────────────\n") + fmt.Fprintf(o.ErrOut, "Total: %d changes\n", len(events)) + } + return nil +} + +// printChangeHeader prints a nicely formatted header for each change +func (o *HistoryOptions) printChangeHeader(changeNum int, timestamp, verb, username string, status *metav1.Status, useColor bool) { + if useColor { + // Box drawing characters for a nice border + fmt.Fprintf(o.Out, "\n\033[2m╭─────────────────────────────────────────────────────────────╮\033[0m\n") + + // Change number with emoji + var verbEmoji string + var verbColor string + switch verb { + case "create": + verbEmoji = "✨" + verbColor = "\033[32m" // green + case "update", "patch": + verbEmoji = "📝" + verbColor = "\033[33m" // yellow + case "delete": + verbEmoji = "🗑️" + verbColor = "\033[31m" // red + default: + verbEmoji = "•" + verbColor = "\033[0m" + } + + fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[1;36mChange #%-3d\033[0m %s %s%-8s\033[0m", changeNum, verbEmoji, verbColor, verb) + + // Status code with color + if status != nil { + statusColor := "\033[32m" // green for success + if status.Code >= 400 { + statusColor = "\033[31m" // red for errors + } + fmt.Fprintf(o.Out, " %s[%d]\033[0m", statusColor, status.Code) + } + fmt.Fprintf(o.Out, "\n") + + fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[90m🕐 %s\033[0m\n", timestamp) + fmt.Fprintf(o.Out, "\033[2m│\033[0m \033[90m👤 %s\033[0m\n", username) + fmt.Fprintf(o.Out, "\033[2m╰─────────────────────────────────────────────────────────────╯\033[0m") + } else { + fmt.Fprintf(o.Out, "\n╭─────────────────────────────────────────────────────────────╮\n") + fmt.Fprintf(o.Out, "│ Change #%-3d %-8s", changeNum, verb) + if status != nil { + fmt.Fprintf(o.Out, " [%d]", status.Code) + } + fmt.Fprintf(o.Out, "\n") + fmt.Fprintf(o.Out, "│ %s\n", timestamp) + fmt.Fprintf(o.Out, "│ %s\n", username) + fmt.Fprintf(o.Out, "╰─────────────────────────────────────────────────────────────╯") + } +} + +// cleanObjectForDiff removes noisy fields from objects to make diffs cleaner +func (o *HistoryOptions) cleanObjectForDiff(obj map[string]interface{}) map[string]interface{} { + cleaned := make(map[string]interface{}) + + // Copy everything except metadata noise + for k, v := range obj { + // Skip these metadata fields that change on every update + if k == "metadata" { + if meta, ok := v.(map[string]interface{}); ok { + cleanedMeta := make(map[string]interface{}) + for mk, mv := range meta { + // Keep only useful metadata + switch mk { + case "name", "namespace", "labels", "annotations": + cleanedMeta[mk] = mv + } + } + if len(cleanedMeta) > 0 { + cleaned[k] = cleanedMeta + } + } + } else if k != "managedFields" && k != "resourceVersion" && k != "generation" && k != "uid" { + cleaned[k] = v + } + } + + return cleaned +} + +// summarizeChanges provides a one-line summary of what changed +func (o *HistoryOptions) summarizeChanges(prev, curr map[string]interface{}) string { + changes := []string{} + + // Track changed top-level fields + for k := range curr { + if k == "status" || k == "metadata" { + continue // These are too noisy + } + prevVal, _ := json.Marshal(prev[k]) + currVal, _ := json.Marshal(curr[k]) + if string(prevVal) != string(currVal) { + changes = append(changes, k) + } + } + + // Check for removed fields + for k := range prev { + if k == "status" || k == "metadata" { + continue + } + if _, exists := curr[k]; !exists { + changes = append(changes, k+" (removed)") + } + } + + if len(changes) == 0 { + return "metadata only" + } + + if len(changes) > 3 { + return fmt.Sprintf("%s and %d more fields", strings.Join(changes[:3], ", "), len(changes)-3) + } + + return strings.Join(changes, ", ") +} + +// printObjectPretty prints a JSON object with syntax highlighting +func (o *HistoryOptions) printObjectPretty(obj map[string]interface{}, useColor bool) error { + objJSON, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) + } + + if useColor { + // Simple JSON syntax highlighting + highlighted := o.highlightJSON(string(objJSON)) + fmt.Fprintln(o.Out, highlighted) + } else { + fmt.Fprintln(o.Out, string(objJSON)) + } + return nil +} + +// highlightJSON adds basic syntax highlighting to JSON +func (o *HistoryOptions) highlightJSON(jsonStr string) string { + const ( + colorKey = "\033[36m" // cyan for keys + colorString = "\033[33m" // yellow for string values + colorNumber = "\033[35m" // magenta for numbers + colorBool = "\033[32m" // green for booleans + colorNull = "\033[90m" // gray for null + colorReset = "\033[0m" + ) + + lines := strings.Split(jsonStr, "\n") + for i, line := range lines { + // Highlight keys (simplified - looks for "key":) + if strings.Contains(line, ":") { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := parts[0] + value := parts[1] + + // Colorize the key + key = strings.ReplaceAll(key, `"`, colorKey+`"`+colorReset) + + // Colorize the value based on type + value = strings.TrimSpace(value) + if strings.HasPrefix(value, `"`) { + // String value + value = colorString + value + colorReset + } else if value == "true" || value == "false" { + // Boolean + value = colorBool + value + colorReset + } else if value == "null" || value == "null," { + // Null + value = colorNull + value + colorReset + } else if len(value) > 0 && (value[0] >= '0' && value[0] <= '9') { + // Number + value = colorNumber + value + colorReset + } + + lines[i] = key + ":" + " " + value + } + } + } + + return strings.Join(lines, "\n") +} + +// printObjectDiff prints a unified diff between two objects +func (o *HistoryOptions) printObjectDiff(prev, curr map[string]interface{}) error { + prevJSON, err := json.MarshalIndent(prev, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal previous object: %w", err) + } + + currJSON, err := json.MarshalIndent(curr, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal current object: %w", err) + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(string(prevJSON)), + B: difflib.SplitLines(string(currJSON)), + FromFile: "Previous", + ToFile: "Current", + Context: 3, + } + + diffText, err := difflib.GetUnifiedDiffString(diff) + if err != nil { + return fmt.Errorf("failed to generate diff: %w", err) + } + + if diffText == "" { + fmt.Fprintf(o.Out, "(no changes detected)\n") + } else { + // Colorize the diff output if terminal supports it + colorizedDiff := o.colorizeDiff(diffText) + fmt.Fprint(o.Out, colorizedDiff) + } + + return nil +} + +// colorizeDiff adds ANSI color codes to diff output +func (o *HistoryOptions) colorizeDiff(diff string) string { + // Check if output is a terminal that supports color + if !o.supportsColor() { + return diff + } + + // ANSI color codes + const ( + colorReset = "\033[0m" + colorRed = "\033[31m" // for deletions (-) + colorGreen = "\033[32m" // for additions (+) + colorCyan = "\033[36m" // for file headers (@@ and ---) + colorBold = "\033[1m" // for header emphasis + ) + + lines := strings.Split(diff, "\n") + colorizedLines := make([]string, len(lines)) + + for i, line := range lines { + if len(line) == 0 { + colorizedLines[i] = line + continue + } + + switch { + case strings.HasPrefix(line, "---") || strings.HasPrefix(line, "+++"): + // File headers + colorizedLines[i] = colorBold + colorCyan + line + colorReset + case strings.HasPrefix(line, "@@"): + // Hunk headers + colorizedLines[i] = colorCyan + line + colorReset + case strings.HasPrefix(line, "-"): + // Deletions + colorizedLines[i] = colorRed + line + colorReset + case strings.HasPrefix(line, "+"): + // Additions + colorizedLines[i] = colorGreen + line + colorReset + default: + // Context lines + colorizedLines[i] = line + } + } + + return strings.Join(colorizedLines, "\n") +} + +// supportsColor checks if the output stream supports ANSI color codes +func (o *HistoryOptions) supportsColor() bool { + // Check if NO_COLOR environment variable is set (universal opt-out) + if os.Getenv("NO_COLOR") != "" { + return false + } + + // Check if output is a terminal (not redirected to a file) + if o.Out != os.Stdout { + return false + } + + // Check TERM environment variable + termEnv := os.Getenv("TERM") + if termEnv == "dumb" || termEnv == "" { + return false + } + + // Check if stdout is a terminal using term.IsTerminal + // os.Stdout.Fd() returns the file descriptor for stdout + return term.IsTerminal(int(os.Stdout.Fd())) +} + +// printObject prints a JSON object with indentation +func (o *HistoryOptions) printObject(obj map[string]interface{}) error { + objJSON, err := json.MarshalIndent(obj, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal object: %w", err) + } + fmt.Fprintln(o.Out, string(objJSON)) + return nil +} + +// printEvents prints audit events using the configured printer +func (o *HistoryOptions) printEvents(events []auditv1.Event, printer printers.ResourcePrinter) error { + eventList := &auditv1.EventList{ + TypeMeta: metav1.TypeMeta{ + Kind: "EventList", + APIVersion: "audit.k8s.io/v1", + }, + Items: events, + } + return printer.PrintObj(eventList, o.Out) +} + +// eventsToTable converts audit events to a Table object +func (o *HistoryOptions) eventsToTable(events []auditv1.Event) *metav1.Table { + return &metav1.Table{ + TypeMeta: metav1.TypeMeta{ + Kind: "Table", + APIVersion: "meta.k8s.io/v1", + }, + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Timestamp", Type: "string", Description: "Time of the event"}, + {Name: "Verb", Type: "string", Description: "Action performed"}, + {Name: "User", Type: "string", Description: "User who performed the action"}, + {Name: "Status", Type: "string", Description: "HTTP status code"}, + }, + Rows: o.eventsToRows(events), + } +} + +// eventsToRows converts audit events to table rows +func (o *HistoryOptions) eventsToRows(events []auditv1.Event) []metav1.TableRow { + rows := make([]metav1.TableRow, 0, len(events)) + for i := range events { + timestamp := events[i].StageTimestamp.Time.Format("2006-01-02 15:04:05") + verb := events[i].Verb + username := events[i].User.Username + + status := "" + if events[i].ResponseStatus != nil { + status = fmt.Sprintf("%d", events[i].ResponseStatus.Code) + } + + row := metav1.TableRow{ + Cells: []interface{}{timestamp, verb, username, status}, + } + rows = append(rows, row) + } + return rows +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 78b7af0b..8cf0b037 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -1,84 +1,84 @@ -package cmd - -import ( - "os" - - "github.com/spf13/cobra" - "k8s.io/cli-runtime/pkg/genericclioptions" - "k8s.io/kubectl/pkg/cmd/util" -) - -// ActivityCommandOptions contains options for creating the activity command -type ActivityCommandOptions struct { - // Factory is the kubectl factory to use for building clients. - // If nil, a default factory will be created. - Factory util.Factory - - // IOStreams for command input/output. - // If not set, defaults to os.Stdin/Stdout/Stderr. - IOStreams genericclioptions.IOStreams - - // ConfigFlags for kubeconfig management. - // If nil and Factory is nil, default ConfigFlags will be created. - // This field is ignored if Factory is provided. - ConfigFlags *genericclioptions.ConfigFlags -} - -// NewActivityCommand creates the root command for the activity CLI -// with the provided options. This allows external clients to provide their own -// factory, IO streams, or config flags. Pass an empty ActivityCommandOptions{} -// to use defaults. -func NewActivityCommand(opts ActivityCommandOptions) *cobra.Command { - // Set up IO streams - ioStreams := opts.IOStreams - if ioStreams.In == nil { - ioStreams.In = os.Stdin - } - if ioStreams.Out == nil { - ioStreams.Out = os.Stdout - } - if ioStreams.ErrOut == nil { - ioStreams.ErrOut = os.Stderr - } - - // Set up factory and config flags - var f util.Factory - var kubeConfigFlags *genericclioptions.ConfigFlags - - if opts.Factory != nil { - // Use provided factory - f = opts.Factory - } else { - // Create default factory - if opts.ConfigFlags != nil { - kubeConfigFlags = opts.ConfigFlags - } else { - kubeConfigFlags = genericclioptions.NewConfigFlags(true) - } - matchVersionKubeConfigFlags := util.NewMatchVersionFlags(kubeConfigFlags) - f = util.NewFactory(matchVersionKubeConfigFlags) - } - - cmd := &cobra.Command{ - Use: "activity", - Short: "Query control plane audit logs", - Long: `The activity plugin provides commands to query and analyze audit logs -stored in your control plane's activity API server. - -Use this tool to investigate incidents, track resource changes, generate compliance -reports, or analyze user activity.`, - SilenceUsage: true, - } - - // Add global kubeconfig flags to root command if we created them - // (external factory may manage its own flags) - if kubeConfigFlags != nil { - kubeConfigFlags.AddFlags(cmd.PersistentFlags()) - } - - // Add subcommands - cmd.AddCommand(NewQueryCommand(f, ioStreams)) - cmd.AddCommand(NewHistoryCommand(f, ioStreams)) - - return cmd -} +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/kubectl/pkg/cmd/util" +) + +// ActivityCommandOptions contains options for creating the activity command +type ActivityCommandOptions struct { + // Factory is the kubectl factory to use for building clients. + // If nil, a default factory will be created. + Factory util.Factory + + // IOStreams for command input/output. + // If not set, defaults to os.Stdin/Stdout/Stderr. + IOStreams genericclioptions.IOStreams + + // ConfigFlags for kubeconfig management. + // If nil and Factory is nil, default ConfigFlags will be created. + // This field is ignored if Factory is provided. + ConfigFlags *genericclioptions.ConfigFlags +} + +// NewActivityCommand creates the root command for the activity CLI +// with the provided options. This allows external clients to provide their own +// factory, IO streams, or config flags. Pass an empty ActivityCommandOptions{} +// to use defaults. +func NewActivityCommand(opts ActivityCommandOptions) *cobra.Command { + // Set up IO streams + ioStreams := opts.IOStreams + if ioStreams.In == nil { + ioStreams.In = os.Stdin + } + if ioStreams.Out == nil { + ioStreams.Out = os.Stdout + } + if ioStreams.ErrOut == nil { + ioStreams.ErrOut = os.Stderr + } + + // Set up factory and config flags + var f util.Factory + var kubeConfigFlags *genericclioptions.ConfigFlags + + if opts.Factory != nil { + // Use provided factory + f = opts.Factory + } else { + // Create default factory + if opts.ConfigFlags != nil { + kubeConfigFlags = opts.ConfigFlags + } else { + kubeConfigFlags = genericclioptions.NewConfigFlags(true) + } + matchVersionKubeConfigFlags := util.NewMatchVersionFlags(kubeConfigFlags) + f = util.NewFactory(matchVersionKubeConfigFlags) + } + + cmd := &cobra.Command{ + Use: "activity", + Short: "Query control plane audit logs", + Long: `The activity plugin provides commands to query and analyze audit logs +stored in your control plane's activity API server. + +Use this tool to investigate incidents, track resource changes, generate compliance +reports, or analyze user activity.`, + SilenceUsage: true, + } + + // Add global kubeconfig flags to root command if we created them + // (external factory may manage its own flags) + if kubeConfigFlags != nil { + kubeConfigFlags.AddFlags(cmd.PersistentFlags()) + } + + // Add subcommands + cmd.AddCommand(NewQueryCommand(f, ioStreams)) + cmd.AddCommand(NewHistoryCommand(f, ioStreams)) + + return cmd +} diff --git a/pkg/mcp/tools/tools.go b/pkg/mcp/tools/tools.go index 4e6efd75..18ac7f71 100644 --- a/pkg/mcp/tools/tools.go +++ b/pkg/mcp/tools/tools.go @@ -1,1650 +1,1650 @@ -// Package tools provides MCP (Model Context Protocol) tools for interacting with -// the Activity service. These tools can be used standalone or embedded into an -// external MCP server. -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/modelcontextprotocol/go-sdk/mcp" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/clientcmd" - - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" - activityclient "go.miloapis.com/activity/pkg/client/clientset/versioned/typed/activity/v1alpha1" -) - -// ToolProvider provides MCP tools for interacting with the Activity API. -// It wraps an Activity API client and exposes query capabilities as MCP tools. -type ToolProvider struct { - client activityclient.ActivityV1alpha1Interface - namespace string -} - -// Config contains configuration for the ToolProvider. -type Config struct { - // Kubeconfig is the path to a kubeconfig file. - // If empty, uses in-cluster config or default kubeconfig location. - Kubeconfig string - - // Context is the kubeconfig context to use. - // If empty, uses the current context. - Context string - - // Namespace for namespaced resources (e.g., Activities). - // If empty, uses "default". - Namespace string -} - -// NewToolProvider creates a new ToolProvider with the given configuration. -func NewToolProvider(cfg Config) (*ToolProvider, error) { - var restConfig *rest.Config - var err error - - if cfg.Kubeconfig != "" { - // Load from specified kubeconfig file - loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: cfg.Kubeconfig} - configOverrides := &clientcmd.ConfigOverrides{} - if cfg.Context != "" { - configOverrides.CurrentContext = cfg.Context - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - restConfig, err = kubeConfig.ClientConfig() - } else { - // Try in-cluster config first, fall back to default kubeconfig - restConfig, err = rest.InClusterConfig() - if err != nil { - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - configOverrides := &clientcmd.ConfigOverrides{} - if cfg.Context != "" { - configOverrides.CurrentContext = cfg.Context - } - kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) - restConfig, err = kubeConfig.ClientConfig() - } - } - - if err != nil { - return nil, fmt.Errorf("failed to create kubernetes config: %w", err) - } - - client, err := activityclient.NewForConfig(restConfig) - if err != nil { - return nil, fmt.Errorf("failed to create activity client: %w", err) - } - - namespace := cfg.Namespace - if namespace == "" { - namespace = "default" - } - - return &ToolProvider{ - client: client, - namespace: namespace, - }, nil -} - -// NewToolProviderWithClient creates a ToolProvider with an existing client. -// This is useful for embedding the tools into an existing application. -func NewToolProviderWithClient(client activityclient.ActivityV1alpha1Interface, namespace string) *ToolProvider { - if namespace == "" { - namespace = "default" - } - return &ToolProvider{ - client: client, - namespace: namespace, - } -} - -// Close releases resources held by the ToolProvider. -func (p *ToolProvider) Close() error { - // Kubernetes client doesn't need explicit cleanup - return nil -} - -// RegisterTools registers all activity tools with an MCP server. -func (p *ToolProvider) RegisterTools(server *mcp.Server) { - // Audit log tools - mcp.AddTool(server, &mcp.Tool{ - Name: "query_audit_logs", - Description: "Search audit logs from the Kubernetes control plane. Use this to investigate incidents, track resource changes, or analyze user activity. Results are returned newest-first.", - }, p.handleQueryAuditLogs) - - mcp.AddTool(server, &mcp.Tool{ - Name: "get_audit_log_facets", - Description: "Get distinct values and counts for audit log fields. Use this to discover what verbs, users, resources, and namespaces appear in the audit logs. Useful for building filters or understanding activity patterns.", - }, p.handleGetAuditLogFacets) - - // Activity tools (human-readable summaries) - mcp.AddTool(server, &mcp.Tool{ - Name: "query_activities", - Description: "Search human-readable activity summaries. Activities are translated from audit logs into friendly descriptions like 'alice created HTTP proxy api-gateway'. Use this to understand what changed in plain language.", - }, p.handleQueryActivities) - - mcp.AddTool(server, &mcp.Tool{ - Name: "get_activity_facets", - Description: "Get distinct values and counts for activity fields. Discover who's active, what resources are changing, and whether changes are human or automated. Valid fields: spec.changeSource, spec.actor.name, spec.actor.type, spec.resource.apiGroup, spec.resource.kind, spec.resource.namespace.", - }, p.handleGetActivityFacets) - - // Investigation tools - mcp.AddTool(server, &mcp.Tool{ - Name: "find_failed_operations", - Description: "Find operations that failed (HTTP 4xx/5xx responses). Use this to debug permission issues, find failed deployments, or investigate security events.", - }, p.handleFindFailedOperations) - - mcp.AddTool(server, &mcp.Tool{ - Name: "get_resource_history", - Description: "Get the change history for a specific resource. See who changed what, when, with field-level diffs where available. Use this to understand how a resource evolved over time.", - }, p.handleGetResourceHistory) - - mcp.AddTool(server, &mcp.Tool{ - Name: "get_user_activity_summary", - Description: "Get a summary of a specific user's recent actions. See what resources they modified, when, and how often. Useful for security reviews and understanding user behavior.", - }, p.handleGetUserActivitySummary) - - // Analytics tools - mcp.AddTool(server, &mcp.Tool{ - Name: "get_activity_timeline", - Description: "Get activity counts grouped by time buckets (hourly/daily). Use this to visualize activity patterns, identify peak periods, and correlate with incidents.", - }, p.handleGetActivityTimeline) - - mcp.AddTool(server, &mcp.Tool{ - Name: "summarize_recent_activity", - Description: "Generate a summary of recent activity including top actors, most changed resources, and key highlights. Perfect for status updates and handoffs.", - }, p.handleSummarizeRecentActivity) - - mcp.AddTool(server, &mcp.Tool{ - Name: "compare_activity_periods", - Description: "Compare activity between two time periods. Identify what changed, new actors, increased/decreased activity. Use this for incident investigation and trend analysis.", - }, p.handleCompareActivityPeriods) - - // Policy tools - mcp.AddTool(server, &mcp.Tool{ - Name: "list_activity_policies", - Description: "List configured ActivityPolicies that translate audit logs into human-readable summaries. See what resource types have translation rules and their status.", - }, p.handleListActivityPolicies) - - mcp.AddTool(server, &mcp.Tool{ - Name: "preview_activity_policy", - Description: "Test an ActivityPolicy against sample audit events to see what activities would be generated. Use this to develop and debug policies before deployment.", - }, p.handlePreviewActivityPolicy) - - // Event tools - mcp.AddTool(server, &mcp.Tool{ - Name: "query_events", - Description: "Search control plane events stored in the Activity service. Events capture resource lifecycle changes, provisioning status, warnings, and errors. Use this to investigate issues, debug deployments, or monitor system health. Results are returned newest-first.", - }, p.handleQueryEvents) - - mcp.AddTool(server, &mcp.Tool{ - Name: "get_event_facets", - Description: "Get distinct values and counts for event fields. Use this to discover what event types, reasons, source components, and involved resources appear in the event stream. Useful for building filters or understanding event patterns.", - }, p.handleGetEventFacets) -} - -// ============================================================================= -// Query Audit Logs -// ============================================================================= - -// QueryAuditLogsArgs contains the arguments for the query_audit_logs tool. -type QueryAuditLogsArgs struct { - // StartTime is the beginning of the search window. - StartTime string `json:"startTime"` - - // EndTime is the end of the search window. - EndTime string `json:"endTime"` - - // Filter is a CEL filter expression to narrow results. - Filter string `json:"filter,omitempty"` - - // Limit is the maximum number of results to return. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleQueryAuditLogs(ctx context.Context, req *mcp.CallToolRequest, args QueryAuditLogsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 100 - } - - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-query-", - }, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: args.StartTime, - EndTime: args.EndTime, - Filter: args.Filter, - Limit: limit, - }, - } - - result, err := p.client.AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - output := map[string]any{ - "count": len(result.Status.Results), - "continue": result.Status.Continue, - "effectiveStartTime": result.Status.EffectiveStartTime, - "effectiveEndTime": result.Status.EffectiveEndTime, - "events": result.Status.Results, - } - - return jsonResult(output) -} - -// ============================================================================= -// Get Audit Log Facets -// ============================================================================= - -// GetAuditLogFacetsArgs contains the arguments for the get_audit_log_facets tool. -type GetAuditLogFacetsArgs struct { - // Fields to get facets for. - Fields []string `json:"fields"` - - // StartTime is the beginning of the time window. - StartTime string `json:"startTime,omitempty"` - - // EndTime is the end of the time window. - EndTime string `json:"endTime,omitempty"` - - // Filter is a CEL filter to narrow down audit logs before computing facets. - Filter string `json:"filter,omitempty"` - - // Limit is the maximum number of distinct values per field. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleGetAuditLogFacets(ctx context.Context, req *mcp.CallToolRequest, args GetAuditLogFacetsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 20 - } - - startTime := args.StartTime - if startTime == "" { - startTime = "now-7d" - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) - for _, field := range args.Fields { - facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ - Field: field, - Limit: limit, - }) - } - - query := &v1alpha1.AuditLogFacetsQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-facets-", - }, - Spec: v1alpha1.AuditLogFacetsQuerySpec{ - TimeRange: v1alpha1.FacetTimeRange{ - Start: startTime, - End: endTime, - }, - Filter: args.Filter, - Facets: facetSpecs, - }, - } - - result, err := p.client.AuditLogFacetsQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - output := make(map[string]any) - for _, facet := range result.Status.Facets { - values := make([]map[string]any, 0, len(facet.Values)) - for _, v := range facet.Values { - values = append(values, map[string]any{ - "value": v.Value, - "count": v.Count, - }) - } - output[facet.Field] = values - } - - return jsonResult(output) -} - -// ============================================================================= -// Query Activities -// ============================================================================= - -// QueryActivitiesArgs contains the arguments for the query_activities tool. -type QueryActivitiesArgs struct { - // StartTime is the beginning of the search window. - StartTime string `json:"startTime"` - - // EndTime is the end of the search window. - EndTime string `json:"endTime"` - - // ChangeSource filters by change source. - ChangeSource string `json:"changeSource,omitempty"` - - // ActorName filters by actor name. - ActorName string `json:"actorName,omitempty"` - - // ResourceKind filters by resource kind. - ResourceKind string `json:"resourceKind,omitempty"` - - // APIGroup filters by API group. - APIGroup string `json:"apiGroup,omitempty"` - - // Search performs full-text search on summary. - Search string `json:"search,omitempty"` - - // Limit is the maximum number of results to return. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleQueryActivities(ctx context.Context, req *mcp.CallToolRequest, args QueryActivitiesArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 100 - } - - query := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-activity-query-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: args.StartTime, - EndTime: args.EndTime, - Search: args.Search, - Limit: limit, - }, - } - - result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Format results for readability - activities := make([]map[string]any, 0, len(result.Status.Results)) - for _, activity := range result.Status.Results { - activityMap := map[string]any{ - "name": activity.Name, - "summary": activity.Spec.Summary, - "changeSource": activity.Spec.ChangeSource, - "actor": map[string]any{ - "type": activity.Spec.Actor.Type, - "name": activity.Spec.Actor.Name, - }, - "resource": map[string]any{ - "apiGroup": activity.Spec.Resource.APIGroup, - "kind": activity.Spec.Resource.Kind, - "name": activity.Spec.Resource.Name, - "namespace": activity.Spec.Resource.Namespace, - }, - "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), - } - activities = append(activities, activityMap) - } - - output := map[string]any{ - "count": len(activities), - "continue": result.Status.Continue, - "effectiveStartTime": result.Status.EffectiveStartTime, - "effectiveEndTime": result.Status.EffectiveEndTime, - "activities": activities, - } - - return jsonResult(output) -} - -// ============================================================================= -// Get Activity Facets -// ============================================================================= - -// GetActivityFacetsArgs contains the arguments for the get_activity_facets tool. -type GetActivityFacetsArgs struct { - // Fields to get facets for. - // Valid values: spec.changeSource, spec.actor.name, spec.actor.type, - // spec.resource.apiGroup, spec.resource.kind, spec.resource.namespace - Fields []string `json:"fields"` - - // StartTime is the beginning of the time window. - StartTime string `json:"startTime,omitempty"` - - // EndTime is the end of the time window. - EndTime string `json:"endTime,omitempty"` - - // Filter narrows the activities before computing facets. - Filter string `json:"filter,omitempty"` - - // Limit is the maximum number of distinct values per field. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleGetActivityFacets(ctx context.Context, req *mcp.CallToolRequest, args GetActivityFacetsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 20 - } - - startTime := args.StartTime - if startTime == "" { - startTime = "now-7d" - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) - for _, field := range args.Fields { - facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ - Field: field, - Limit: limit, - }) - } - - query := &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-activity-facets-", - }, - Spec: v1alpha1.ActivityFacetQuerySpec{ - TimeRange: v1alpha1.FacetTimeRange{ - Start: startTime, - End: endTime, - }, - Filter: args.Filter, - Facets: facetSpecs, - }, - } - - result, err := p.client.ActivityFacetQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - output := make(map[string]any) - for _, facet := range result.Status.Facets { - values := make([]map[string]any, 0, len(facet.Values)) - for _, v := range facet.Values { - values = append(values, map[string]any{ - "value": v.Value, - "count": v.Count, - }) - } - output[facet.Field] = values - } - - return jsonResult(output) -} - -// ============================================================================= -// Find Failed Operations -// ============================================================================= - -// FindFailedOperationsArgs contains the arguments for the find_failed_operations tool. -// Note: This tool queries audit logs directly since Activities don't capture failed operations. -type FindFailedOperationsArgs struct { - // StartTime is the beginning of the search window. - StartTime string `json:"startTime"` - - // EndTime is the end of the search window. - EndTime string `json:"endTime,omitempty"` - - // StatusCodeMin is the minimum status code to include. - StatusCodeMin int `json:"statusCodeMin,omitempty"` - - // StatusCodeMax is the maximum status code to include. - StatusCodeMax int `json:"statusCodeMax,omitempty"` - - // Username filters by actor. - Username string `json:"username,omitempty"` - - // Resource filters by resource type. - Resource string `json:"resource,omitempty"` - - // Verb filters by verb. - Verb string `json:"verb,omitempty"` - - // Limit is the maximum number of results. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleFindFailedOperations(ctx context.Context, req *mcp.CallToolRequest, args FindFailedOperationsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 100 - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - statusCodeMin := args.StatusCodeMin - if statusCodeMin == 0 { - statusCodeMin = 400 - } - - statusCodeMax := args.StatusCodeMax - if statusCodeMax == 0 { - statusCodeMax = 599 - } - - // Build CEL filter for failed operations - filters := []string{ - fmt.Sprintf("responseStatus.code >= %d", statusCodeMin), - fmt.Sprintf("responseStatus.code <= %d", statusCodeMax), - } - - if args.Username != "" { - filters = append(filters, fmt.Sprintf("user.username == '%s'", args.Username)) - } - if args.Resource != "" { - filters = append(filters, fmt.Sprintf("objectRef.resource == '%s'", args.Resource)) - } - if args.Verb != "" { - filters = append(filters, fmt.Sprintf("verb == '%s'", args.Verb)) - } - - filter := strings.Join(filters, " && ") - - // Note: Failed operations are queried from audit logs since Activities - // are only created for successful operations that match ActivityPolicies. - query := &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-failed-ops-", - }, - Spec: v1alpha1.AuditLogQuerySpec{ - StartTime: args.StartTime, - EndTime: endTime, - Filter: filter, - Limit: limit, - }, - } - - result, err := p.client.AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Count by status code - statusCodeCounts := make(map[int]int) - failures := make([]map[string]any, 0, len(result.Status.Results)) - - for _, event := range result.Status.Results { - code := int(event.ResponseStatus.Code) - statusCodeCounts[code]++ - - failure := map[string]any{ - "timestamp": event.RequestReceivedTimestamp.Format("2006-01-02T15:04:05Z"), - "user": event.User.Username, - "verb": event.Verb, - "resource": event.ObjectRef.Resource, - "name": event.ObjectRef.Name, - "namespace": event.ObjectRef.Namespace, - "statusCode": code, - } - - if event.ResponseStatus.Message != "" { - failure["message"] = event.ResponseStatus.Message - } - - failures = append(failures, failure) - } - - output := map[string]any{ - "count": len(failures), - "byStatusCode": statusCodeCounts, - "failures": failures, - } - - return jsonResult(output) -} - -// ============================================================================= -// Get Resource History -// ============================================================================= - -// GetResourceHistoryArgs contains the arguments for the get_resource_history tool. -type GetResourceHistoryArgs struct { - // ResourceUID is the UID of the resource. - ResourceUID string `json:"resourceUID,omitempty"` - - // APIGroup of the resource. - APIGroup string `json:"apiGroup,omitempty"` - - // Kind of the resource. - Kind string `json:"kind,omitempty"` - - // Name of the resource. - Name string `json:"name,omitempty"` - - // Namespace of the resource. - Namespace string `json:"namespace,omitempty"` - - // StartTime limits history to after this time. - StartTime string `json:"startTime,omitempty"` - - // EndTime limits history to before this time. - EndTime string `json:"endTime,omitempty"` - - // Limit is the maximum number of events to return. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleGetResourceHistory(ctx context.Context, req *mcp.CallToolRequest, args GetResourceHistoryArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 100 - } - - startTime := args.StartTime - if startTime == "" { - startTime = "now-30d" - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - if args.ResourceUID == "" && args.Name == "" { - return errorResult("Either resourceUID or name is required"), nil, nil - } - - // Query activities for this resource - query := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-resource-history-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: startTime, - EndTime: endTime, - Limit: limit, - }, - } - - result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Filter by name if specified (ActivityQuery doesn't support name filter directly) - history := make([]map[string]any, 0, len(result.Status.Results)) - for _, activity := range result.Status.Results { - // Skip if name filter specified and doesn't match - if args.Name != "" && activity.Spec.Resource.Name != args.Name { - continue - } - - entry := map[string]any{ - "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), - "actor": activity.Spec.Actor.Name, - "summary": activity.Spec.Summary, - "changeSource": activity.Spec.ChangeSource, - } - - history = append(history, entry) - } - - // Build resource identifier for output - resource := map[string]any{ - "name": args.Name, - "kind": args.Kind, - "apiGroup": args.APIGroup, - "namespace": args.Namespace, - } - if len(result.Status.Results) > 0 { - r := result.Status.Results[0].Spec.Resource - resource["apiGroup"] = r.APIGroup - resource["kind"] = r.Kind - resource["name"] = r.Name - resource["namespace"] = r.Namespace - } - - output := map[string]any{ - "resource": resource, - "count": len(history), - "timeRange": map[string]any{"start": result.Status.EffectiveStartTime, "end": result.Status.EffectiveEndTime}, - "history": history, - } - - return jsonResult(output) -} - -// ============================================================================= -// Get User Activity Summary -// ============================================================================= - -// GetUserActivitySummaryArgs contains the arguments for the get_user_activity_summary tool. -type GetUserActivitySummaryArgs struct { - // Username is the username or email to get activity for. - Username string `json:"username,omitempty"` - - // StartTime is the beginning of the time window. - StartTime string `json:"startTime,omitempty"` - - // EndTime is the end of the time window. - EndTime string `json:"endTime,omitempty"` - - // IncludeDetails includes individual activities in the response. - IncludeDetails bool `json:"includeDetails,omitempty"` -} - -func (p *ToolProvider) handleGetUserActivitySummary(ctx context.Context, req *mcp.CallToolRequest, args GetUserActivitySummaryArgs) (*mcp.CallToolResult, any, error) { - if args.Username == "" { - return errorResult("Username is required"), nil, nil - } - - startTime := args.StartTime - if startTime == "" { - startTime = "now-7d" - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - // Query activities by actor name - query := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-user-summary-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: startTime, - EndTime: endTime, - Limit: 1000, // Get more activities for summary - }, - } - - result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Build summary - changeSourceCounts := make(map[string]int) - resourceKindCounts := make(map[string]int) - dayCounts := make(map[string]int) - var recentActivities []map[string]any - - for i, activity := range result.Status.Results { - changeSourceCounts[activity.Spec.ChangeSource]++ - resourceKindCounts[activity.Spec.Resource.Kind]++ - - day := activity.CreationTimestamp.Format("2006-01-02") - dayCounts[day]++ - - if args.IncludeDetails && i < 20 { - recentActivities = append(recentActivities, map[string]any{ - "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), - "summary": activity.Spec.Summary, - "changeSource": activity.Spec.ChangeSource, - "resource": map[string]any{ - "kind": activity.Spec.Resource.Kind, - "name": activity.Spec.Resource.Name, - "namespace": activity.Spec.Resource.Namespace, - }, - }) - } - } - - // Convert day counts to sorted list - dayList := make([]map[string]any, 0, len(dayCounts)) - for day, count := range dayCounts { - dayList = append(dayList, map[string]any{ - "date": day, - "count": count, - }) - } - - output := map[string]any{ - "user": map[string]any{ - "username": args.Username, - }, - "timeRange": map[string]any{ - "start": result.Status.EffectiveStartTime, - "end": result.Status.EffectiveEndTime, - }, - "totalActivities": len(result.Status.Results), - "breakdown": map[string]any{ - "byChangeSource": changeSourceCounts, - "byResourceKind": resourceKindCounts, - "byDay": dayList, - }, - } - - if args.IncludeDetails && len(recentActivities) > 0 { - output["recentActivities"] = recentActivities - } - - return jsonResult(output) -} - -// ============================================================================= -// Get Activity Timeline -// ============================================================================= - -// GetActivityTimelineArgs contains the arguments for the get_activity_timeline tool. -type GetActivityTimelineArgs struct { - // StartTime is the beginning of the timeline. - StartTime string `json:"startTime"` - - // EndTime is the end of the timeline. - EndTime string `json:"endTime,omitempty"` - - // BucketSize is the time bucket size (hour, day). - BucketSize string `json:"bucketSize,omitempty"` - - // ChangeSource filters by change source (human, system). - ChangeSource string `json:"changeSource,omitempty"` -} - -func (p *ToolProvider) handleGetActivityTimeline(ctx context.Context, req *mcp.CallToolRequest, args GetActivityTimelineArgs) (*mcp.CallToolResult, any, error) { - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - query := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-timeline-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: args.StartTime, - EndTime: endTime, - Limit: 1000, - }, - } - - result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Determine bucket size - bucketFormat := "2006-01-02T15:00:00Z" // hourly default - bucketSize := args.BucketSize - if bucketSize == "" { - bucketSize = "hour" - } - - if bucketSize == "day" { - bucketFormat = "2006-01-02T00:00:00Z" - } - - // Count by bucket - bucketCounts := make(map[string]int) - var peakBucket string - var peakCount int - - for _, activity := range result.Status.Results { - bucket := activity.CreationTimestamp.Format(bucketFormat) - bucketCounts[bucket]++ - - if bucketCounts[bucket] > peakCount { - peakCount = bucketCounts[bucket] - peakBucket = bucket - } - } - - // Convert to sorted list - buckets := make([]map[string]any, 0, len(bucketCounts)) - for bucket, count := range bucketCounts { - entry := map[string]any{ - "timestamp": bucket, - "count": count, - } - if bucket == peakBucket { - entry["note"] = "peak" - } - buckets = append(buckets, entry) - } - - // Calculate average - var avg float64 - if len(buckets) > 0 { - avg = float64(len(result.Status.Results)) / float64(len(buckets)) - } - - output := map[string]any{ - "timeRange": map[string]any{ - "start": result.Status.EffectiveStartTime, - "end": result.Status.EffectiveEndTime, - }, - "bucketSize": bucketSize, - "totalCount": len(result.Status.Results), - "buckets": buckets, - "peakBucket": map[string]any{"timestamp": peakBucket, "count": peakCount}, - "averagePerBucket": avg, - } - - return jsonResult(output) -} - -// ============================================================================= -// Summarize Recent Activity -// ============================================================================= - -// SummarizeRecentActivityArgs contains the arguments for the summarize_recent_activity tool. -type SummarizeRecentActivityArgs struct { - // StartTime is the beginning of the summary window. - StartTime string `json:"startTime"` - - // EndTime is the end of the summary window. - EndTime string `json:"endTime,omitempty"` - - // ChangeSource filters by change source (human, system). - ChangeSource string `json:"changeSource,omitempty"` - - // TopN is the number of top items per category. - TopN int `json:"topN,omitempty"` -} - -func (p *ToolProvider) handleSummarizeRecentActivity(ctx context.Context, req *mcp.CallToolRequest, args SummarizeRecentActivityArgs) (*mcp.CallToolResult, any, error) { - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - topN := args.TopN - if topN == 0 { - topN = 5 - } - - query := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-summary-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: args.StartTime, - EndTime: endTime, - Limit: 1000, - }, - } - - result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Build summary statistics - actorCounts := make(map[string]int) - resourceKindCounts := make(map[string]int) - var humanChanges, systemChanges int - var recentSummaries []string - - for i, activity := range result.Status.Results { - actorCounts[activity.Spec.Actor.Name]++ - resourceKindCounts[activity.Spec.Resource.Kind]++ - - // Classify as human or system - if activity.Spec.ChangeSource == "human" { - humanChanges++ - } else { - systemChanges++ - } - - // Collect recent summaries - if i < topN { - recentSummaries = append(recentSummaries, activity.Spec.Summary) - } - } - - // Get top actors and resources - topActors := getTopN(actorCounts, topN) - topResources := getTopN(resourceKindCounts, topN) - - // Build highlights - highlights := []string{ - fmt.Sprintf("%d total activities (%d human, %d system)", len(result.Status.Results), humanChanges, systemChanges), - } - - if len(topActors) > 0 { - highlights = append(highlights, fmt.Sprintf("Most active: %s (%d activities)", topActors[0]["name"], topActors[0]["count"])) - } - - if len(topResources) > 0 { - highlights = append(highlights, fmt.Sprintf("Most changed resource type: %s (%d activities)", topResources[0]["name"], topResources[0]["count"])) - } - - output := map[string]any{ - "timeRange": map[string]any{ - "start": result.Status.EffectiveStartTime, - "end": result.Status.EffectiveEndTime, - }, - "totalActivities": len(result.Status.Results), - "humanChanges": humanChanges, - "systemChanges": systemChanges, - "highlights": highlights, - "topActors": topActors, - "topResources": topResources, - "recentSummaries": recentSummaries, - } - - return jsonResult(output) -} - -// ============================================================================= -// Compare Activity Periods -// ============================================================================= - -// CompareActivityPeriodsArgs contains the arguments for the compare_activity_periods tool. -type CompareActivityPeriodsArgs struct { - // BaselineStart is the start of the baseline period. - BaselineStart string `json:"baselineStart"` - - // BaselineEnd is the end of the baseline period. - BaselineEnd string `json:"baselineEnd"` - - // ComparisonStart is the start of the comparison period. - ComparisonStart string `json:"comparisonStart"` - - // ComparisonEnd is the end of the comparison period. - ComparisonEnd string `json:"comparisonEnd"` -} - -func (p *ToolProvider) handleCompareActivityPeriods(ctx context.Context, req *mcp.CallToolRequest, args CompareActivityPeriodsArgs) (*mcp.CallToolResult, any, error) { - // Query baseline period - baselineQuery := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-compare-baseline-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: args.BaselineStart, - EndTime: args.BaselineEnd, - Limit: 1000, - }, - } - - baselineResult, err := p.client.ActivityQueries().Create(ctx, baselineQuery, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Baseline query failed: %v", err)), nil, nil - } - - // Query comparison period - comparisonQuery := &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-compare-comparison-", - }, - Spec: v1alpha1.ActivityQuerySpec{ - StartTime: args.ComparisonStart, - EndTime: args.ComparisonEnd, - Limit: 1000, - }, - } - - comparisonResult, err := p.client.ActivityQueries().Create(ctx, comparisonQuery, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Comparison query failed: %v", err)), nil, nil - } - - // Build counts for both periods - baselineCounts := buildActivityCounts(baselineResult.Status.Results) - comparisonCounts := buildActivityCounts(comparisonResult.Status.Results) - - // Find differences - newInComparison := findNew(baselineCounts.actors, comparisonCounts.actors) - increasedActivity := findIncreased(baselineCounts.resourceKinds, comparisonCounts.resourceKinds) - decreasedActivity := findDecreased(baselineCounts.resourceKinds, comparisonCounts.resourceKinds) - - // Calculate change percentage - var changePercent float64 - if baselineCounts.total > 0 { - changePercent = float64(comparisonCounts.total-baselineCounts.total) / float64(baselineCounts.total) * 100 - } - - output := map[string]any{ - "baseline": map[string]any{ - "start": baselineResult.Status.EffectiveStartTime, - "end": baselineResult.Status.EffectiveEndTime, - "count": baselineCounts.total, - }, - "comparison": map[string]any{ - "start": comparisonResult.Status.EffectiveStartTime, - "end": comparisonResult.Status.EffectiveEndTime, - "count": comparisonCounts.total, - }, - "changePercent": changePercent, - "newInComparison": newInComparison, - "increasedActivity": increasedActivity, - "decreasedActivity": decreasedActivity, - } - - // Add analysis summary - direction := "more" - if changePercent < 0 { - direction = "less" - } - analysis := fmt.Sprintf("Comparison period shows %.0f%% %s activity.", absFloat(changePercent), direction) - - if len(newInComparison) > 0 { - analysis += fmt.Sprintf(" %d new actors appeared.", len(newInComparison)) - } - - output["analysis"] = analysis - - return jsonResult(output) -} - -// ============================================================================= -// List Activity Policies -// ============================================================================= - -// ListActivityPoliciesArgs contains the arguments for the list_activity_policies tool. -type ListActivityPoliciesArgs struct { - // APIGroup filters by resource API group. - APIGroup string `json:"apiGroup,omitempty"` - - // Kind filters by resource kind. - Kind string `json:"kind,omitempty"` - - // IncludeRules includes full rule definitions in output. - IncludeRules bool `json:"includeRules,omitempty"` -} - -func (p *ToolProvider) handleListActivityPolicies(ctx context.Context, req *mcp.CallToolRequest, args ListActivityPoliciesArgs) (*mcp.CallToolResult, any, error) { - result, err := p.client.ActivityPolicies().List(ctx, metav1.ListOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - policies := make([]map[string]any, 0, len(result.Items)) - for _, policy := range result.Items { - // Apply filters - if args.APIGroup != "" && policy.Spec.Resource.APIGroup != args.APIGroup { - continue - } - if args.Kind != "" && policy.Spec.Resource.Kind != args.Kind { - continue - } - - policyMap := map[string]any{ - "name": policy.Name, - "resource": map[string]any{ - "apiGroup": policy.Spec.Resource.APIGroup, - "kind": policy.Spec.Resource.Kind, - }, - "auditRuleCount": len(policy.Spec.AuditRules), - "eventRuleCount": len(policy.Spec.EventRules), - } - - // Get status - status := "Unknown" - for _, cond := range policy.Status.Conditions { - if cond.Type == "Ready" { - if cond.Status == "True" { - status = "Ready" - } else { - status = cond.Reason - } - break - } - } - policyMap["status"] = status - - if args.IncludeRules { - policyMap["auditRules"] = policy.Spec.AuditRules - policyMap["eventRules"] = policy.Spec.EventRules - } - - policies = append(policies, policyMap) - } - - output := map[string]any{ - "policies": policies, - "summary": fmt.Sprintf("%d policies covering %d resource types", len(policies), len(policies)), - } - - return jsonResult(output) -} - -// ============================================================================= -// Preview Activity Policy -// ============================================================================= - -// PreviewActivityPolicyArgs contains the arguments for the preview_activity_policy tool. -type PreviewActivityPolicyArgs struct { - // Policy is the ActivityPolicy spec to test. - Policy v1alpha1.ActivityPolicySpec `json:"policy"` - - // Inputs are sample audit/event inputs to test. - Inputs []v1alpha1.PolicyPreviewInput `json:"inputs"` -} - -func (p *ToolProvider) handlePreviewActivityPolicy(ctx context.Context, req *mcp.CallToolRequest, args PreviewActivityPolicyArgs) (*mcp.CallToolResult, any, error) { - preview := &v1alpha1.PolicyPreview{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-preview-", - }, - Spec: v1alpha1.PolicyPreviewSpec{ - Policy: args.Policy, - Inputs: args.Inputs, - }, - } - - result, err := p.client.PolicyPreviews().Create(ctx, preview, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Preview failed: %v", err)), nil, nil - } - - if result.Status.Error != "" { - return errorResult(fmt.Sprintf("Preview error: %s", result.Status.Error)), nil, nil - } - - // Format results - results := make([]map[string]any, 0, len(result.Status.Results)) - for _, r := range result.Status.Results { - resultMap := map[string]any{ - "inputIndex": r.InputIndex, - "matched": r.Matched, - } - - if r.Matched { - resultMap["matchedRule"] = map[string]any{ - "type": r.MatchedRuleType, - "index": r.MatchedRuleIndex, - } - } - - if r.Error != "" { - resultMap["error"] = r.Error - } - - results = append(results, resultMap) - } - - // Format generated activities - activities := make([]map[string]any, 0, len(result.Status.Activities)) - for _, a := range result.Status.Activities { - activities = append(activities, map[string]any{ - "summary": a.Spec.Summary, - "actor": map[string]any{ - "type": a.Spec.Actor.Type, - "name": a.Spec.Actor.Name, - }, - "resource": map[string]any{ - "kind": a.Spec.Resource.Kind, - "name": a.Spec.Resource.Name, - }, - }) - } - - output := map[string]any{ - "results": results, - "activities": activities, - } - - return jsonResult(output) -} - -// ============================================================================= -// Query Events -// ============================================================================= - -// QueryEventsArgs contains the arguments for the query_events tool. -type QueryEventsArgs struct { - // StartTime is the beginning of the search window. - StartTime string `json:"startTime"` - - // EndTime is the end of the search window. - EndTime string `json:"endTime"` - - // Namespace limits results to events from a specific namespace. - Namespace string `json:"namespace,omitempty"` - - // RegardingKind filters by the kind of the regarding object. - RegardingKind string `json:"regardingKind,omitempty"` - - // RegardingName filters by the name of the regarding object. - RegardingName string `json:"regardingName,omitempty"` - - // Reason filters by event reason. - Reason string `json:"reason,omitempty"` - - // Type filters by event type (Normal or Warning). - Type string `json:"type,omitempty"` - - // SourceComponent filters by source component. - SourceComponent string `json:"sourceComponent,omitempty"` - - // Limit is the maximum number of results to return. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleQueryEvents(ctx context.Context, req *mcp.CallToolRequest, args QueryEventsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 100 - } - - // Build field selector for filtering - var fieldSelectors []string - if args.RegardingKind != "" { - fieldSelectors = append(fieldSelectors, fmt.Sprintf("regarding.kind=%s", args.RegardingKind)) - } - if args.RegardingName != "" { - fieldSelectors = append(fieldSelectors, fmt.Sprintf("regarding.name=%s", args.RegardingName)) - } - if args.Reason != "" { - fieldSelectors = append(fieldSelectors, fmt.Sprintf("reason=%s", args.Reason)) - } - if args.Type != "" { - fieldSelectors = append(fieldSelectors, fmt.Sprintf("type=%s", args.Type)) - } - if args.SourceComponent != "" { - fieldSelectors = append(fieldSelectors, fmt.Sprintf("source.component=%s", args.SourceComponent)) - } - - fieldSelector := "" - if len(fieldSelectors) > 0 { - fieldSelector = strings.Join(fieldSelectors, ",") - } - - query := &v1alpha1.EventQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-event-query-", - }, - Spec: v1alpha1.EventQuerySpec{ - StartTime: args.StartTime, - EndTime: args.EndTime, - Namespace: args.Namespace, - FieldSelector: fieldSelector, - Limit: limit, - }, - } - - result, err := p.client.EventQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - // Format results for readability - // EventRecord wraps eventsv1.Event, so access event data via record.Event - events := make([]map[string]any, 0, len(result.Status.Results)) - for _, record := range result.Status.Results { - event := record.Event - eventMap := map[string]any{ - "name": event.Name, - "namespace": event.Namespace, - "type": event.Type, - "reason": event.Reason, - "message": event.Note, - } - - // eventsv1.Event uses Regarding - if event.Regarding.Name != "" || event.Regarding.Kind != "" { - eventMap["regarding"] = map[string]any{ - "kind": event.Regarding.Kind, - "name": event.Regarding.Name, - "namespace": event.Regarding.Namespace, - } - } - - // eventsv1.Event uses ReportingController/ReportingInstance instead of Source - eventMap["source"] = map[string]any{ - "component": event.ReportingController, - "host": event.ReportingInstance, - } - - // eventsv1.Event uses Series.Count (Series is pointer), otherwise default to 1 - if event.Series != nil { - eventMap["count"] = event.Series.Count - } else { - eventMap["count"] = int32(1) - } - - // eventsv1 uses EventTime, or Series.LastObservedTime for recurring events - if event.Series != nil && !event.Series.LastObservedTime.IsZero() { - eventMap["timestamp"] = event.Series.LastObservedTime.Format("2006-01-02T15:04:05Z") - } else if !event.EventTime.IsZero() { - eventMap["timestamp"] = event.EventTime.Format("2006-01-02T15:04:05Z") - } - - events = append(events, eventMap) - } - - output := map[string]any{ - "count": len(events), - "continue": result.Status.Continue, - "effectiveStartTime": result.Status.EffectiveStartTime, - "effectiveEndTime": result.Status.EffectiveEndTime, - "events": events, - } - - return jsonResult(output) -} - -// ============================================================================= -// Get Event Facets -// ============================================================================= - -// GetEventFacetsArgs contains the arguments for the get_event_facets tool. -type GetEventFacetsArgs struct { - // Fields to get facets for. - Fields []string `json:"fields"` - - // StartTime is the beginning of the time window for facet aggregation. - StartTime string `json:"startTime,omitempty"` - - // EndTime is the end of the time window for facet aggregation. - EndTime string `json:"endTime,omitempty"` - - // Limit is the maximum number of distinct values per field. - Limit int `json:"limit,omitempty"` -} - -func (p *ToolProvider) handleGetEventFacets(ctx context.Context, req *mcp.CallToolRequest, args GetEventFacetsArgs) (*mcp.CallToolResult, any, error) { - limit := int32(args.Limit) - if limit == 0 { - limit = 20 - } - - startTime := args.StartTime - if startTime == "" { - startTime = "now-7d" - } - - endTime := args.EndTime - if endTime == "" { - endTime = "now" - } - - facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) - for _, field := range args.Fields { - facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ - Field: field, - Limit: limit, - }) - } - - query := &v1alpha1.EventFacetQuery{ - ObjectMeta: metav1.ObjectMeta{ - GenerateName: "mcp-event-facets-", - }, - Spec: v1alpha1.EventFacetQuerySpec{ - TimeRange: v1alpha1.FacetTimeRange{ - Start: startTime, - End: endTime, - }, - Facets: facetSpecs, - }, - } - - result, err := p.client.EventFacetQueries().Create(ctx, query, metav1.CreateOptions{}) - if err != nil { - return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil - } - - output := make(map[string]any) - for _, facet := range result.Status.Facets { - values := make([]map[string]any, 0, len(facet.Values)) - for _, v := range facet.Values { - values = append(values, map[string]any{ - "value": v.Value, - "count": v.Count, - }) - } - output[facet.Field] = values - } - - return jsonResult(output) -} - -// ============================================================================= -// Helper Functions -// ============================================================================= - -func textResult(text string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: text}, - }, - } -} - -func errorResult(message string) *mcp.CallToolResult { - return &mcp.CallToolResult{ - IsError: true, - Content: []mcp.Content{ - &mcp.TextContent{Text: message}, - }, - } -} - -func jsonResult(output any) (*mcp.CallToolResult, any, error) { - jsonBytes, err := json.MarshalIndent(output, "", " ") - if err != nil { - return errorResult(fmt.Sprintf("Failed to format results: %v", err)), nil, nil - } - return textResult(string(jsonBytes)), nil, nil -} - -func isSystemUser(username string) bool { - return strings.HasPrefix(username, "system:") || - strings.Contains(username, "serviceaccount") || - strings.Contains(username, "controller") -} - -func getTopN(counts map[string]int, n int) []map[string]any { - // Convert to slice and sort - type kv struct { - Key string - Value int - } - var sorted []kv - for k, v := range counts { - sorted = append(sorted, kv{k, v}) - } - - // Sort by count descending - for i := 0; i < len(sorted)-1; i++ { - for j := i + 1; j < len(sorted); j++ { - if sorted[j].Value > sorted[i].Value { - sorted[i], sorted[j] = sorted[j], sorted[i] - } - } - } - - // Take top N - result := make([]map[string]any, 0, n) - for i := 0; i < len(sorted) && i < n; i++ { - result = append(result, map[string]any{ - "name": sorted[i].Key, - "count": sorted[i].Value, - }) - } - return result -} - -type activityCounts struct { - total int - actors map[string]int - resourceKinds map[string]int - changeSources map[string]int -} - -func buildActivityCounts(activities []v1alpha1.Activity) activityCounts { - counts := activityCounts{ - total: len(activities), - actors: make(map[string]int), - resourceKinds: make(map[string]int), - changeSources: make(map[string]int), - } - - for _, activity := range activities { - counts.actors[activity.Spec.Actor.Name]++ - counts.resourceKinds[activity.Spec.Resource.Kind]++ - counts.changeSources[activity.Spec.ChangeSource]++ - } - - return counts -} - -func findNew(baseline, comparison map[string]int) []map[string]any { - var result []map[string]any - for k, v := range comparison { - if _, exists := baseline[k]; !exists { - result = append(result, map[string]any{ - "name": k, - "count": v, - "note": "Not present in baseline", - }) - } - } - return result -} - -func findIncreased(baseline, comparison map[string]int) []map[string]any { - var result []map[string]any - for k, v := range comparison { - if baselineV, exists := baseline[k]; exists && v > baselineV { - changePercent := float64(v-baselineV) / float64(baselineV) * 100 - if changePercent >= 50 { // Only include significant increases - result = append(result, map[string]any{ - "name": k, - "baseline": baselineV, - "comparison": v, - "changePercent": changePercent, - }) - } - } - } - return result -} - -func findDecreased(baseline, comparison map[string]int) []map[string]any { - var result []map[string]any - for k, v := range baseline { - if compV, exists := comparison[k]; exists && compV < v { - changePercent := float64(v-compV) / float64(v) * 100 - if changePercent >= 50 { // Only include significant decreases - result = append(result, map[string]any{ - "name": k, - "baseline": v, - "comparison": compV, - "changePercent": -changePercent, - }) - } - } - } - return result -} - -func absFloat(f float64) float64 { - if f < 0 { - return -f - } - return f -} +// Package tools provides MCP (Model Context Protocol) tools for interacting with +// the Activity service. These tools can be used standalone or embedded into an +// external MCP server. +package tools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" + activityclient "go.miloapis.com/activity/pkg/client/clientset/versioned/typed/activity/v1alpha1" +) + +// ToolProvider provides MCP tools for interacting with the Activity API. +// It wraps an Activity API client and exposes query capabilities as MCP tools. +type ToolProvider struct { + client activityclient.ActivityV1alpha1Interface + namespace string +} + +// Config contains configuration for the ToolProvider. +type Config struct { + // Kubeconfig is the path to a kubeconfig file. + // If empty, uses in-cluster config or default kubeconfig location. + Kubeconfig string + + // Context is the kubeconfig context to use. + // If empty, uses the current context. + Context string + + // Namespace for namespaced resources (e.g., Activities). + // If empty, uses "default". + Namespace string +} + +// NewToolProvider creates a new ToolProvider with the given configuration. +func NewToolProvider(cfg Config) (*ToolProvider, error) { + var restConfig *rest.Config + var err error + + if cfg.Kubeconfig != "" { + // Load from specified kubeconfig file + loadingRules := &clientcmd.ClientConfigLoadingRules{ExplicitPath: cfg.Kubeconfig} + configOverrides := &clientcmd.ConfigOverrides{} + if cfg.Context != "" { + configOverrides.CurrentContext = cfg.Context + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + restConfig, err = kubeConfig.ClientConfig() + } else { + // Try in-cluster config first, fall back to default kubeconfig + restConfig, err = rest.InClusterConfig() + if err != nil { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + if cfg.Context != "" { + configOverrides.CurrentContext = cfg.Context + } + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + restConfig, err = kubeConfig.ClientConfig() + } + } + + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes config: %w", err) + } + + client, err := activityclient.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create activity client: %w", err) + } + + namespace := cfg.Namespace + if namespace == "" { + namespace = "default" + } + + return &ToolProvider{ + client: client, + namespace: namespace, + }, nil +} + +// NewToolProviderWithClient creates a ToolProvider with an existing client. +// This is useful for embedding the tools into an existing application. +func NewToolProviderWithClient(client activityclient.ActivityV1alpha1Interface, namespace string) *ToolProvider { + if namespace == "" { + namespace = "default" + } + return &ToolProvider{ + client: client, + namespace: namespace, + } +} + +// Close releases resources held by the ToolProvider. +func (p *ToolProvider) Close() error { + // Kubernetes client doesn't need explicit cleanup + return nil +} + +// RegisterTools registers all activity tools with an MCP server. +func (p *ToolProvider) RegisterTools(server *mcp.Server) { + // Audit log tools + mcp.AddTool(server, &mcp.Tool{ + Name: "query_audit_logs", + Description: "Search audit logs from the Kubernetes control plane. Use this to investigate incidents, track resource changes, or analyze user activity. Results are returned newest-first.", + }, p.handleQueryAuditLogs) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_audit_log_facets", + Description: "Get distinct values and counts for audit log fields. Use this to discover what verbs, users, resources, and namespaces appear in the audit logs. Useful for building filters or understanding activity patterns.", + }, p.handleGetAuditLogFacets) + + // Activity tools (human-readable summaries) + mcp.AddTool(server, &mcp.Tool{ + Name: "query_activities", + Description: "Search human-readable activity summaries. Activities are translated from audit logs into friendly descriptions like 'alice created HTTP proxy api-gateway'. Use this to understand what changed in plain language.", + }, p.handleQueryActivities) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_activity_facets", + Description: "Get distinct values and counts for activity fields. Discover who's active, what resources are changing, and whether changes are human or automated. Valid fields: spec.changeSource, spec.actor.name, spec.actor.type, spec.resource.apiGroup, spec.resource.kind, spec.resource.namespace.", + }, p.handleGetActivityFacets) + + // Investigation tools + mcp.AddTool(server, &mcp.Tool{ + Name: "find_failed_operations", + Description: "Find operations that failed (HTTP 4xx/5xx responses). Use this to debug permission issues, find failed deployments, or investigate security events.", + }, p.handleFindFailedOperations) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_resource_history", + Description: "Get the change history for a specific resource. See who changed what, when, with field-level diffs where available. Use this to understand how a resource evolved over time.", + }, p.handleGetResourceHistory) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_user_activity_summary", + Description: "Get a summary of a specific user's recent actions. See what resources they modified, when, and how often. Useful for security reviews and understanding user behavior.", + }, p.handleGetUserActivitySummary) + + // Analytics tools + mcp.AddTool(server, &mcp.Tool{ + Name: "get_activity_timeline", + Description: "Get activity counts grouped by time buckets (hourly/daily). Use this to visualize activity patterns, identify peak periods, and correlate with incidents.", + }, p.handleGetActivityTimeline) + + mcp.AddTool(server, &mcp.Tool{ + Name: "summarize_recent_activity", + Description: "Generate a summary of recent activity including top actors, most changed resources, and key highlights. Perfect for status updates and handoffs.", + }, p.handleSummarizeRecentActivity) + + mcp.AddTool(server, &mcp.Tool{ + Name: "compare_activity_periods", + Description: "Compare activity between two time periods. Identify what changed, new actors, increased/decreased activity. Use this for incident investigation and trend analysis.", + }, p.handleCompareActivityPeriods) + + // Policy tools + mcp.AddTool(server, &mcp.Tool{ + Name: "list_activity_policies", + Description: "List configured ActivityPolicies that translate audit logs into human-readable summaries. See what resource types have translation rules and their status.", + }, p.handleListActivityPolicies) + + mcp.AddTool(server, &mcp.Tool{ + Name: "preview_activity_policy", + Description: "Test an ActivityPolicy against sample audit events to see what activities would be generated. Use this to develop and debug policies before deployment.", + }, p.handlePreviewActivityPolicy) + + // Event tools + mcp.AddTool(server, &mcp.Tool{ + Name: "query_events", + Description: "Search control plane events stored in the Activity service. Events capture resource lifecycle changes, provisioning status, warnings, and errors. Use this to investigate issues, debug deployments, or monitor system health. Results are returned newest-first.", + }, p.handleQueryEvents) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_event_facets", + Description: "Get distinct values and counts for event fields. Use this to discover what event types, reasons, source components, and involved resources appear in the event stream. Useful for building filters or understanding event patterns.", + }, p.handleGetEventFacets) +} + +// ============================================================================= +// Query Audit Logs +// ============================================================================= + +// QueryAuditLogsArgs contains the arguments for the query_audit_logs tool. +type QueryAuditLogsArgs struct { + // StartTime is the beginning of the search window. + StartTime string `json:"startTime"` + + // EndTime is the end of the search window. + EndTime string `json:"endTime"` + + // Filter is a CEL filter expression to narrow results. + Filter string `json:"filter,omitempty"` + + // Limit is the maximum number of results to return. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleQueryAuditLogs(ctx context.Context, req *mcp.CallToolRequest, args QueryAuditLogsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 100 + } + + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-query-", + }, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: args.StartTime, + EndTime: args.EndTime, + Filter: args.Filter, + Limit: limit, + }, + } + + result, err := p.client.AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + output := map[string]any{ + "count": len(result.Status.Results), + "continue": result.Status.Continue, + "effectiveStartTime": result.Status.EffectiveStartTime, + "effectiveEndTime": result.Status.EffectiveEndTime, + "events": result.Status.Results, + } + + return jsonResult(output) +} + +// ============================================================================= +// Get Audit Log Facets +// ============================================================================= + +// GetAuditLogFacetsArgs contains the arguments for the get_audit_log_facets tool. +type GetAuditLogFacetsArgs struct { + // Fields to get facets for. + Fields []string `json:"fields"` + + // StartTime is the beginning of the time window. + StartTime string `json:"startTime,omitempty"` + + // EndTime is the end of the time window. + EndTime string `json:"endTime,omitempty"` + + // Filter is a CEL filter to narrow down audit logs before computing facets. + Filter string `json:"filter,omitempty"` + + // Limit is the maximum number of distinct values per field. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleGetAuditLogFacets(ctx context.Context, req *mcp.CallToolRequest, args GetAuditLogFacetsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 20 + } + + startTime := args.StartTime + if startTime == "" { + startTime = "now-7d" + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) + for _, field := range args.Fields { + facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ + Field: field, + Limit: limit, + }) + } + + query := &v1alpha1.AuditLogFacetsQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-facets-", + }, + Spec: v1alpha1.AuditLogFacetsQuerySpec{ + TimeRange: v1alpha1.FacetTimeRange{ + Start: startTime, + End: endTime, + }, + Filter: args.Filter, + Facets: facetSpecs, + }, + } + + result, err := p.client.AuditLogFacetsQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + output := make(map[string]any) + for _, facet := range result.Status.Facets { + values := make([]map[string]any, 0, len(facet.Values)) + for _, v := range facet.Values { + values = append(values, map[string]any{ + "value": v.Value, + "count": v.Count, + }) + } + output[facet.Field] = values + } + + return jsonResult(output) +} + +// ============================================================================= +// Query Activities +// ============================================================================= + +// QueryActivitiesArgs contains the arguments for the query_activities tool. +type QueryActivitiesArgs struct { + // StartTime is the beginning of the search window. + StartTime string `json:"startTime"` + + // EndTime is the end of the search window. + EndTime string `json:"endTime"` + + // ChangeSource filters by change source. + ChangeSource string `json:"changeSource,omitempty"` + + // ActorName filters by actor name. + ActorName string `json:"actorName,omitempty"` + + // ResourceKind filters by resource kind. + ResourceKind string `json:"resourceKind,omitempty"` + + // APIGroup filters by API group. + APIGroup string `json:"apiGroup,omitempty"` + + // Search performs full-text search on summary. + Search string `json:"search,omitempty"` + + // Limit is the maximum number of results to return. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleQueryActivities(ctx context.Context, req *mcp.CallToolRequest, args QueryActivitiesArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 100 + } + + query := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-activity-query-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: args.StartTime, + EndTime: args.EndTime, + Search: args.Search, + Limit: limit, + }, + } + + result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Format results for readability + activities := make([]map[string]any, 0, len(result.Status.Results)) + for _, activity := range result.Status.Results { + activityMap := map[string]any{ + "name": activity.Name, + "summary": activity.Spec.Summary, + "changeSource": activity.Spec.ChangeSource, + "actor": map[string]any{ + "type": activity.Spec.Actor.Type, + "name": activity.Spec.Actor.Name, + }, + "resource": map[string]any{ + "apiGroup": activity.Spec.Resource.APIGroup, + "kind": activity.Spec.Resource.Kind, + "name": activity.Spec.Resource.Name, + "namespace": activity.Spec.Resource.Namespace, + }, + "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), + } + activities = append(activities, activityMap) + } + + output := map[string]any{ + "count": len(activities), + "continue": result.Status.Continue, + "effectiveStartTime": result.Status.EffectiveStartTime, + "effectiveEndTime": result.Status.EffectiveEndTime, + "activities": activities, + } + + return jsonResult(output) +} + +// ============================================================================= +// Get Activity Facets +// ============================================================================= + +// GetActivityFacetsArgs contains the arguments for the get_activity_facets tool. +type GetActivityFacetsArgs struct { + // Fields to get facets for. + // Valid values: spec.changeSource, spec.actor.name, spec.actor.type, + // spec.resource.apiGroup, spec.resource.kind, spec.resource.namespace + Fields []string `json:"fields"` + + // StartTime is the beginning of the time window. + StartTime string `json:"startTime,omitempty"` + + // EndTime is the end of the time window. + EndTime string `json:"endTime,omitempty"` + + // Filter narrows the activities before computing facets. + Filter string `json:"filter,omitempty"` + + // Limit is the maximum number of distinct values per field. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleGetActivityFacets(ctx context.Context, req *mcp.CallToolRequest, args GetActivityFacetsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 20 + } + + startTime := args.StartTime + if startTime == "" { + startTime = "now-7d" + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) + for _, field := range args.Fields { + facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ + Field: field, + Limit: limit, + }) + } + + query := &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-activity-facets-", + }, + Spec: v1alpha1.ActivityFacetQuerySpec{ + TimeRange: v1alpha1.FacetTimeRange{ + Start: startTime, + End: endTime, + }, + Filter: args.Filter, + Facets: facetSpecs, + }, + } + + result, err := p.client.ActivityFacetQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + output := make(map[string]any) + for _, facet := range result.Status.Facets { + values := make([]map[string]any, 0, len(facet.Values)) + for _, v := range facet.Values { + values = append(values, map[string]any{ + "value": v.Value, + "count": v.Count, + }) + } + output[facet.Field] = values + } + + return jsonResult(output) +} + +// ============================================================================= +// Find Failed Operations +// ============================================================================= + +// FindFailedOperationsArgs contains the arguments for the find_failed_operations tool. +// Note: This tool queries audit logs directly since Activities don't capture failed operations. +type FindFailedOperationsArgs struct { + // StartTime is the beginning of the search window. + StartTime string `json:"startTime"` + + // EndTime is the end of the search window. + EndTime string `json:"endTime,omitempty"` + + // StatusCodeMin is the minimum status code to include. + StatusCodeMin int `json:"statusCodeMin,omitempty"` + + // StatusCodeMax is the maximum status code to include. + StatusCodeMax int `json:"statusCodeMax,omitempty"` + + // Username filters by actor. + Username string `json:"username,omitempty"` + + // Resource filters by resource type. + Resource string `json:"resource,omitempty"` + + // Verb filters by verb. + Verb string `json:"verb,omitempty"` + + // Limit is the maximum number of results. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleFindFailedOperations(ctx context.Context, req *mcp.CallToolRequest, args FindFailedOperationsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 100 + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + statusCodeMin := args.StatusCodeMin + if statusCodeMin == 0 { + statusCodeMin = 400 + } + + statusCodeMax := args.StatusCodeMax + if statusCodeMax == 0 { + statusCodeMax = 599 + } + + // Build CEL filter for failed operations + filters := []string{ + fmt.Sprintf("responseStatus.code >= %d", statusCodeMin), + fmt.Sprintf("responseStatus.code <= %d", statusCodeMax), + } + + if args.Username != "" { + filters = append(filters, fmt.Sprintf("user.username == '%s'", args.Username)) + } + if args.Resource != "" { + filters = append(filters, fmt.Sprintf("objectRef.resource == '%s'", args.Resource)) + } + if args.Verb != "" { + filters = append(filters, fmt.Sprintf("verb == '%s'", args.Verb)) + } + + filter := strings.Join(filters, " && ") + + // Note: Failed operations are queried from audit logs since Activities + // are only created for successful operations that match ActivityPolicies. + query := &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-failed-ops-", + }, + Spec: v1alpha1.AuditLogQuerySpec{ + StartTime: args.StartTime, + EndTime: endTime, + Filter: filter, + Limit: limit, + }, + } + + result, err := p.client.AuditLogQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Count by status code + statusCodeCounts := make(map[int]int) + failures := make([]map[string]any, 0, len(result.Status.Results)) + + for _, event := range result.Status.Results { + code := int(event.ResponseStatus.Code) + statusCodeCounts[code]++ + + failure := map[string]any{ + "timestamp": event.RequestReceivedTimestamp.Format("2006-01-02T15:04:05Z"), + "user": event.User.Username, + "verb": event.Verb, + "resource": event.ObjectRef.Resource, + "name": event.ObjectRef.Name, + "namespace": event.ObjectRef.Namespace, + "statusCode": code, + } + + if event.ResponseStatus.Message != "" { + failure["message"] = event.ResponseStatus.Message + } + + failures = append(failures, failure) + } + + output := map[string]any{ + "count": len(failures), + "byStatusCode": statusCodeCounts, + "failures": failures, + } + + return jsonResult(output) +} + +// ============================================================================= +// Get Resource History +// ============================================================================= + +// GetResourceHistoryArgs contains the arguments for the get_resource_history tool. +type GetResourceHistoryArgs struct { + // ResourceUID is the UID of the resource. + ResourceUID string `json:"resourceUID,omitempty"` + + // APIGroup of the resource. + APIGroup string `json:"apiGroup,omitempty"` + + // Kind of the resource. + Kind string `json:"kind,omitempty"` + + // Name of the resource. + Name string `json:"name,omitempty"` + + // Namespace of the resource. + Namespace string `json:"namespace,omitempty"` + + // StartTime limits history to after this time. + StartTime string `json:"startTime,omitempty"` + + // EndTime limits history to before this time. + EndTime string `json:"endTime,omitempty"` + + // Limit is the maximum number of events to return. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleGetResourceHistory(ctx context.Context, req *mcp.CallToolRequest, args GetResourceHistoryArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 100 + } + + startTime := args.StartTime + if startTime == "" { + startTime = "now-30d" + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + if args.ResourceUID == "" && args.Name == "" { + return errorResult("Either resourceUID or name is required"), nil, nil + } + + // Query activities for this resource + query := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-resource-history-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: startTime, + EndTime: endTime, + Limit: limit, + }, + } + + result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Filter by name if specified (ActivityQuery doesn't support name filter directly) + history := make([]map[string]any, 0, len(result.Status.Results)) + for _, activity := range result.Status.Results { + // Skip if name filter specified and doesn't match + if args.Name != "" && activity.Spec.Resource.Name != args.Name { + continue + } + + entry := map[string]any{ + "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), + "actor": activity.Spec.Actor.Name, + "summary": activity.Spec.Summary, + "changeSource": activity.Spec.ChangeSource, + } + + history = append(history, entry) + } + + // Build resource identifier for output + resource := map[string]any{ + "name": args.Name, + "kind": args.Kind, + "apiGroup": args.APIGroup, + "namespace": args.Namespace, + } + if len(result.Status.Results) > 0 { + r := result.Status.Results[0].Spec.Resource + resource["apiGroup"] = r.APIGroup + resource["kind"] = r.Kind + resource["name"] = r.Name + resource["namespace"] = r.Namespace + } + + output := map[string]any{ + "resource": resource, + "count": len(history), + "timeRange": map[string]any{"start": result.Status.EffectiveStartTime, "end": result.Status.EffectiveEndTime}, + "history": history, + } + + return jsonResult(output) +} + +// ============================================================================= +// Get User Activity Summary +// ============================================================================= + +// GetUserActivitySummaryArgs contains the arguments for the get_user_activity_summary tool. +type GetUserActivitySummaryArgs struct { + // Username is the username or email to get activity for. + Username string `json:"username,omitempty"` + + // StartTime is the beginning of the time window. + StartTime string `json:"startTime,omitempty"` + + // EndTime is the end of the time window. + EndTime string `json:"endTime,omitempty"` + + // IncludeDetails includes individual activities in the response. + IncludeDetails bool `json:"includeDetails,omitempty"` +} + +func (p *ToolProvider) handleGetUserActivitySummary(ctx context.Context, req *mcp.CallToolRequest, args GetUserActivitySummaryArgs) (*mcp.CallToolResult, any, error) { + if args.Username == "" { + return errorResult("Username is required"), nil, nil + } + + startTime := args.StartTime + if startTime == "" { + startTime = "now-7d" + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + // Query activities by actor name + query := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-user-summary-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: startTime, + EndTime: endTime, + Limit: 1000, // Get more activities for summary + }, + } + + result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Build summary + changeSourceCounts := make(map[string]int) + resourceKindCounts := make(map[string]int) + dayCounts := make(map[string]int) + var recentActivities []map[string]any + + for i, activity := range result.Status.Results { + changeSourceCounts[activity.Spec.ChangeSource]++ + resourceKindCounts[activity.Spec.Resource.Kind]++ + + day := activity.CreationTimestamp.Format("2006-01-02") + dayCounts[day]++ + + if args.IncludeDetails && i < 20 { + recentActivities = append(recentActivities, map[string]any{ + "timestamp": activity.CreationTimestamp.Format("2006-01-02T15:04:05Z"), + "summary": activity.Spec.Summary, + "changeSource": activity.Spec.ChangeSource, + "resource": map[string]any{ + "kind": activity.Spec.Resource.Kind, + "name": activity.Spec.Resource.Name, + "namespace": activity.Spec.Resource.Namespace, + }, + }) + } + } + + // Convert day counts to sorted list + dayList := make([]map[string]any, 0, len(dayCounts)) + for day, count := range dayCounts { + dayList = append(dayList, map[string]any{ + "date": day, + "count": count, + }) + } + + output := map[string]any{ + "user": map[string]any{ + "username": args.Username, + }, + "timeRange": map[string]any{ + "start": result.Status.EffectiveStartTime, + "end": result.Status.EffectiveEndTime, + }, + "totalActivities": len(result.Status.Results), + "breakdown": map[string]any{ + "byChangeSource": changeSourceCounts, + "byResourceKind": resourceKindCounts, + "byDay": dayList, + }, + } + + if args.IncludeDetails && len(recentActivities) > 0 { + output["recentActivities"] = recentActivities + } + + return jsonResult(output) +} + +// ============================================================================= +// Get Activity Timeline +// ============================================================================= + +// GetActivityTimelineArgs contains the arguments for the get_activity_timeline tool. +type GetActivityTimelineArgs struct { + // StartTime is the beginning of the timeline. + StartTime string `json:"startTime"` + + // EndTime is the end of the timeline. + EndTime string `json:"endTime,omitempty"` + + // BucketSize is the time bucket size (hour, day). + BucketSize string `json:"bucketSize,omitempty"` + + // ChangeSource filters by change source (human, system). + ChangeSource string `json:"changeSource,omitempty"` +} + +func (p *ToolProvider) handleGetActivityTimeline(ctx context.Context, req *mcp.CallToolRequest, args GetActivityTimelineArgs) (*mcp.CallToolResult, any, error) { + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + query := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-timeline-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: args.StartTime, + EndTime: endTime, + Limit: 1000, + }, + } + + result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Determine bucket size + bucketFormat := "2006-01-02T15:00:00Z" // hourly default + bucketSize := args.BucketSize + if bucketSize == "" { + bucketSize = "hour" + } + + if bucketSize == "day" { + bucketFormat = "2006-01-02T00:00:00Z" + } + + // Count by bucket + bucketCounts := make(map[string]int) + var peakBucket string + var peakCount int + + for _, activity := range result.Status.Results { + bucket := activity.CreationTimestamp.Format(bucketFormat) + bucketCounts[bucket]++ + + if bucketCounts[bucket] > peakCount { + peakCount = bucketCounts[bucket] + peakBucket = bucket + } + } + + // Convert to sorted list + buckets := make([]map[string]any, 0, len(bucketCounts)) + for bucket, count := range bucketCounts { + entry := map[string]any{ + "timestamp": bucket, + "count": count, + } + if bucket == peakBucket { + entry["note"] = "peak" + } + buckets = append(buckets, entry) + } + + // Calculate average + var avg float64 + if len(buckets) > 0 { + avg = float64(len(result.Status.Results)) / float64(len(buckets)) + } + + output := map[string]any{ + "timeRange": map[string]any{ + "start": result.Status.EffectiveStartTime, + "end": result.Status.EffectiveEndTime, + }, + "bucketSize": bucketSize, + "totalCount": len(result.Status.Results), + "buckets": buckets, + "peakBucket": map[string]any{"timestamp": peakBucket, "count": peakCount}, + "averagePerBucket": avg, + } + + return jsonResult(output) +} + +// ============================================================================= +// Summarize Recent Activity +// ============================================================================= + +// SummarizeRecentActivityArgs contains the arguments for the summarize_recent_activity tool. +type SummarizeRecentActivityArgs struct { + // StartTime is the beginning of the summary window. + StartTime string `json:"startTime"` + + // EndTime is the end of the summary window. + EndTime string `json:"endTime,omitempty"` + + // ChangeSource filters by change source (human, system). + ChangeSource string `json:"changeSource,omitempty"` + + // TopN is the number of top items per category. + TopN int `json:"topN,omitempty"` +} + +func (p *ToolProvider) handleSummarizeRecentActivity(ctx context.Context, req *mcp.CallToolRequest, args SummarizeRecentActivityArgs) (*mcp.CallToolResult, any, error) { + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + topN := args.TopN + if topN == 0 { + topN = 5 + } + + query := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-summary-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: args.StartTime, + EndTime: endTime, + Limit: 1000, + }, + } + + result, err := p.client.ActivityQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Build summary statistics + actorCounts := make(map[string]int) + resourceKindCounts := make(map[string]int) + var humanChanges, systemChanges int + var recentSummaries []string + + for i, activity := range result.Status.Results { + actorCounts[activity.Spec.Actor.Name]++ + resourceKindCounts[activity.Spec.Resource.Kind]++ + + // Classify as human or system + if activity.Spec.ChangeSource == "human" { + humanChanges++ + } else { + systemChanges++ + } + + // Collect recent summaries + if i < topN { + recentSummaries = append(recentSummaries, activity.Spec.Summary) + } + } + + // Get top actors and resources + topActors := getTopN(actorCounts, topN) + topResources := getTopN(resourceKindCounts, topN) + + // Build highlights + highlights := []string{ + fmt.Sprintf("%d total activities (%d human, %d system)", len(result.Status.Results), humanChanges, systemChanges), + } + + if len(topActors) > 0 { + highlights = append(highlights, fmt.Sprintf("Most active: %s (%d activities)", topActors[0]["name"], topActors[0]["count"])) + } + + if len(topResources) > 0 { + highlights = append(highlights, fmt.Sprintf("Most changed resource type: %s (%d activities)", topResources[0]["name"], topResources[0]["count"])) + } + + output := map[string]any{ + "timeRange": map[string]any{ + "start": result.Status.EffectiveStartTime, + "end": result.Status.EffectiveEndTime, + }, + "totalActivities": len(result.Status.Results), + "humanChanges": humanChanges, + "systemChanges": systemChanges, + "highlights": highlights, + "topActors": topActors, + "topResources": topResources, + "recentSummaries": recentSummaries, + } + + return jsonResult(output) +} + +// ============================================================================= +// Compare Activity Periods +// ============================================================================= + +// CompareActivityPeriodsArgs contains the arguments for the compare_activity_periods tool. +type CompareActivityPeriodsArgs struct { + // BaselineStart is the start of the baseline period. + BaselineStart string `json:"baselineStart"` + + // BaselineEnd is the end of the baseline period. + BaselineEnd string `json:"baselineEnd"` + + // ComparisonStart is the start of the comparison period. + ComparisonStart string `json:"comparisonStart"` + + // ComparisonEnd is the end of the comparison period. + ComparisonEnd string `json:"comparisonEnd"` +} + +func (p *ToolProvider) handleCompareActivityPeriods(ctx context.Context, req *mcp.CallToolRequest, args CompareActivityPeriodsArgs) (*mcp.CallToolResult, any, error) { + // Query baseline period + baselineQuery := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-compare-baseline-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: args.BaselineStart, + EndTime: args.BaselineEnd, + Limit: 1000, + }, + } + + baselineResult, err := p.client.ActivityQueries().Create(ctx, baselineQuery, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Baseline query failed: %v", err)), nil, nil + } + + // Query comparison period + comparisonQuery := &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-compare-comparison-", + }, + Spec: v1alpha1.ActivityQuerySpec{ + StartTime: args.ComparisonStart, + EndTime: args.ComparisonEnd, + Limit: 1000, + }, + } + + comparisonResult, err := p.client.ActivityQueries().Create(ctx, comparisonQuery, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Comparison query failed: %v", err)), nil, nil + } + + // Build counts for both periods + baselineCounts := buildActivityCounts(baselineResult.Status.Results) + comparisonCounts := buildActivityCounts(comparisonResult.Status.Results) + + // Find differences + newInComparison := findNew(baselineCounts.actors, comparisonCounts.actors) + increasedActivity := findIncreased(baselineCounts.resourceKinds, comparisonCounts.resourceKinds) + decreasedActivity := findDecreased(baselineCounts.resourceKinds, comparisonCounts.resourceKinds) + + // Calculate change percentage + var changePercent float64 + if baselineCounts.total > 0 { + changePercent = float64(comparisonCounts.total-baselineCounts.total) / float64(baselineCounts.total) * 100 + } + + output := map[string]any{ + "baseline": map[string]any{ + "start": baselineResult.Status.EffectiveStartTime, + "end": baselineResult.Status.EffectiveEndTime, + "count": baselineCounts.total, + }, + "comparison": map[string]any{ + "start": comparisonResult.Status.EffectiveStartTime, + "end": comparisonResult.Status.EffectiveEndTime, + "count": comparisonCounts.total, + }, + "changePercent": changePercent, + "newInComparison": newInComparison, + "increasedActivity": increasedActivity, + "decreasedActivity": decreasedActivity, + } + + // Add analysis summary + direction := "more" + if changePercent < 0 { + direction = "less" + } + analysis := fmt.Sprintf("Comparison period shows %.0f%% %s activity.", absFloat(changePercent), direction) + + if len(newInComparison) > 0 { + analysis += fmt.Sprintf(" %d new actors appeared.", len(newInComparison)) + } + + output["analysis"] = analysis + + return jsonResult(output) +} + +// ============================================================================= +// List Activity Policies +// ============================================================================= + +// ListActivityPoliciesArgs contains the arguments for the list_activity_policies tool. +type ListActivityPoliciesArgs struct { + // APIGroup filters by resource API group. + APIGroup string `json:"apiGroup,omitempty"` + + // Kind filters by resource kind. + Kind string `json:"kind,omitempty"` + + // IncludeRules includes full rule definitions in output. + IncludeRules bool `json:"includeRules,omitempty"` +} + +func (p *ToolProvider) handleListActivityPolicies(ctx context.Context, req *mcp.CallToolRequest, args ListActivityPoliciesArgs) (*mcp.CallToolResult, any, error) { + result, err := p.client.ActivityPolicies().List(ctx, metav1.ListOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + policies := make([]map[string]any, 0, len(result.Items)) + for _, policy := range result.Items { + // Apply filters + if args.APIGroup != "" && policy.Spec.Resource.APIGroup != args.APIGroup { + continue + } + if args.Kind != "" && policy.Spec.Resource.Kind != args.Kind { + continue + } + + policyMap := map[string]any{ + "name": policy.Name, + "resource": map[string]any{ + "apiGroup": policy.Spec.Resource.APIGroup, + "kind": policy.Spec.Resource.Kind, + }, + "auditRuleCount": len(policy.Spec.AuditRules), + "eventRuleCount": len(policy.Spec.EventRules), + } + + // Get status + status := "Unknown" + for _, cond := range policy.Status.Conditions { + if cond.Type == "Ready" { + if cond.Status == "True" { + status = "Ready" + } else { + status = cond.Reason + } + break + } + } + policyMap["status"] = status + + if args.IncludeRules { + policyMap["auditRules"] = policy.Spec.AuditRules + policyMap["eventRules"] = policy.Spec.EventRules + } + + policies = append(policies, policyMap) + } + + output := map[string]any{ + "policies": policies, + "summary": fmt.Sprintf("%d policies covering %d resource types", len(policies), len(policies)), + } + + return jsonResult(output) +} + +// ============================================================================= +// Preview Activity Policy +// ============================================================================= + +// PreviewActivityPolicyArgs contains the arguments for the preview_activity_policy tool. +type PreviewActivityPolicyArgs struct { + // Policy is the ActivityPolicy spec to test. + Policy v1alpha1.ActivityPolicySpec `json:"policy"` + + // Inputs are sample audit/event inputs to test. + Inputs []v1alpha1.PolicyPreviewInput `json:"inputs"` +} + +func (p *ToolProvider) handlePreviewActivityPolicy(ctx context.Context, req *mcp.CallToolRequest, args PreviewActivityPolicyArgs) (*mcp.CallToolResult, any, error) { + preview := &v1alpha1.PolicyPreview{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-preview-", + }, + Spec: v1alpha1.PolicyPreviewSpec{ + Policy: args.Policy, + Inputs: args.Inputs, + }, + } + + result, err := p.client.PolicyPreviews().Create(ctx, preview, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Preview failed: %v", err)), nil, nil + } + + if result.Status.Error != "" { + return errorResult(fmt.Sprintf("Preview error: %s", result.Status.Error)), nil, nil + } + + // Format results + results := make([]map[string]any, 0, len(result.Status.Results)) + for _, r := range result.Status.Results { + resultMap := map[string]any{ + "inputIndex": r.InputIndex, + "matched": r.Matched, + } + + if r.Matched { + resultMap["matchedRule"] = map[string]any{ + "type": r.MatchedRuleType, + "index": r.MatchedRuleIndex, + } + } + + if r.Error != "" { + resultMap["error"] = r.Error + } + + results = append(results, resultMap) + } + + // Format generated activities + activities := make([]map[string]any, 0, len(result.Status.Activities)) + for _, a := range result.Status.Activities { + activities = append(activities, map[string]any{ + "summary": a.Spec.Summary, + "actor": map[string]any{ + "type": a.Spec.Actor.Type, + "name": a.Spec.Actor.Name, + }, + "resource": map[string]any{ + "kind": a.Spec.Resource.Kind, + "name": a.Spec.Resource.Name, + }, + }) + } + + output := map[string]any{ + "results": results, + "activities": activities, + } + + return jsonResult(output) +} + +// ============================================================================= +// Query Events +// ============================================================================= + +// QueryEventsArgs contains the arguments for the query_events tool. +type QueryEventsArgs struct { + // StartTime is the beginning of the search window. + StartTime string `json:"startTime"` + + // EndTime is the end of the search window. + EndTime string `json:"endTime"` + + // Namespace limits results to events from a specific namespace. + Namespace string `json:"namespace,omitempty"` + + // RegardingKind filters by the kind of the regarding object. + RegardingKind string `json:"regardingKind,omitempty"` + + // RegardingName filters by the name of the regarding object. + RegardingName string `json:"regardingName,omitempty"` + + // Reason filters by event reason. + Reason string `json:"reason,omitempty"` + + // Type filters by event type (Normal or Warning). + Type string `json:"type,omitempty"` + + // SourceComponent filters by source component. + SourceComponent string `json:"sourceComponent,omitempty"` + + // Limit is the maximum number of results to return. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleQueryEvents(ctx context.Context, req *mcp.CallToolRequest, args QueryEventsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 100 + } + + // Build field selector for filtering + var fieldSelectors []string + if args.RegardingKind != "" { + fieldSelectors = append(fieldSelectors, fmt.Sprintf("regarding.kind=%s", args.RegardingKind)) + } + if args.RegardingName != "" { + fieldSelectors = append(fieldSelectors, fmt.Sprintf("regarding.name=%s", args.RegardingName)) + } + if args.Reason != "" { + fieldSelectors = append(fieldSelectors, fmt.Sprintf("reason=%s", args.Reason)) + } + if args.Type != "" { + fieldSelectors = append(fieldSelectors, fmt.Sprintf("type=%s", args.Type)) + } + if args.SourceComponent != "" { + fieldSelectors = append(fieldSelectors, fmt.Sprintf("source.component=%s", args.SourceComponent)) + } + + fieldSelector := "" + if len(fieldSelectors) > 0 { + fieldSelector = strings.Join(fieldSelectors, ",") + } + + query := &v1alpha1.EventQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-event-query-", + }, + Spec: v1alpha1.EventQuerySpec{ + StartTime: args.StartTime, + EndTime: args.EndTime, + Namespace: args.Namespace, + FieldSelector: fieldSelector, + Limit: limit, + }, + } + + result, err := p.client.EventQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + // Format results for readability + // EventRecord wraps eventsv1.Event, so access event data via record.Event + events := make([]map[string]any, 0, len(result.Status.Results)) + for _, record := range result.Status.Results { + event := record.Event + eventMap := map[string]any{ + "name": event.Name, + "namespace": event.Namespace, + "type": event.Type, + "reason": event.Reason, + "message": event.Note, + } + + // eventsv1.Event uses Regarding + if event.Regarding.Name != "" || event.Regarding.Kind != "" { + eventMap["regarding"] = map[string]any{ + "kind": event.Regarding.Kind, + "name": event.Regarding.Name, + "namespace": event.Regarding.Namespace, + } + } + + // eventsv1.Event uses ReportingController/ReportingInstance instead of Source + eventMap["source"] = map[string]any{ + "component": event.ReportingController, + "host": event.ReportingInstance, + } + + // eventsv1.Event uses Series.Count (Series is pointer), otherwise default to 1 + if event.Series != nil { + eventMap["count"] = event.Series.Count + } else { + eventMap["count"] = int32(1) + } + + // eventsv1 uses EventTime, or Series.LastObservedTime for recurring events + if event.Series != nil && !event.Series.LastObservedTime.IsZero() { + eventMap["timestamp"] = event.Series.LastObservedTime.Format("2006-01-02T15:04:05Z") + } else if !event.EventTime.IsZero() { + eventMap["timestamp"] = event.EventTime.Format("2006-01-02T15:04:05Z") + } + + events = append(events, eventMap) + } + + output := map[string]any{ + "count": len(events), + "continue": result.Status.Continue, + "effectiveStartTime": result.Status.EffectiveStartTime, + "effectiveEndTime": result.Status.EffectiveEndTime, + "events": events, + } + + return jsonResult(output) +} + +// ============================================================================= +// Get Event Facets +// ============================================================================= + +// GetEventFacetsArgs contains the arguments for the get_event_facets tool. +type GetEventFacetsArgs struct { + // Fields to get facets for. + Fields []string `json:"fields"` + + // StartTime is the beginning of the time window for facet aggregation. + StartTime string `json:"startTime,omitempty"` + + // EndTime is the end of the time window for facet aggregation. + EndTime string `json:"endTime,omitempty"` + + // Limit is the maximum number of distinct values per field. + Limit int `json:"limit,omitempty"` +} + +func (p *ToolProvider) handleGetEventFacets(ctx context.Context, req *mcp.CallToolRequest, args GetEventFacetsArgs) (*mcp.CallToolResult, any, error) { + limit := int32(args.Limit) + if limit == 0 { + limit = 20 + } + + startTime := args.StartTime + if startTime == "" { + startTime = "now-7d" + } + + endTime := args.EndTime + if endTime == "" { + endTime = "now" + } + + facetSpecs := make([]v1alpha1.FacetSpec, 0, len(args.Fields)) + for _, field := range args.Fields { + facetSpecs = append(facetSpecs, v1alpha1.FacetSpec{ + Field: field, + Limit: limit, + }) + } + + query := &v1alpha1.EventFacetQuery{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "mcp-event-facets-", + }, + Spec: v1alpha1.EventFacetQuerySpec{ + TimeRange: v1alpha1.FacetTimeRange{ + Start: startTime, + End: endTime, + }, + Facets: facetSpecs, + }, + } + + result, err := p.client.EventFacetQueries().Create(ctx, query, metav1.CreateOptions{}) + if err != nil { + return errorResult(fmt.Sprintf("Query failed: %v", err)), nil, nil + } + + output := make(map[string]any) + for _, facet := range result.Status.Facets { + values := make([]map[string]any, 0, len(facet.Values)) + for _, v := range facet.Values { + values = append(values, map[string]any{ + "value": v.Value, + "count": v.Count, + }) + } + output[facet.Field] = values + } + + return jsonResult(output) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= + +func textResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: text}, + }, + } +} + +func errorResult(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{ + &mcp.TextContent{Text: message}, + }, + } +} + +func jsonResult(output any) (*mcp.CallToolResult, any, error) { + jsonBytes, err := json.MarshalIndent(output, "", " ") + if err != nil { + return errorResult(fmt.Sprintf("Failed to format results: %v", err)), nil, nil + } + return textResult(string(jsonBytes)), nil, nil +} + +func isSystemUser(username string) bool { + return strings.HasPrefix(username, "system:") || + strings.Contains(username, "serviceaccount") || + strings.Contains(username, "controller") +} + +func getTopN(counts map[string]int, n int) []map[string]any { + // Convert to slice and sort + type kv struct { + Key string + Value int + } + var sorted []kv + for k, v := range counts { + sorted = append(sorted, kv{k, v}) + } + + // Sort by count descending + for i := 0; i < len(sorted)-1; i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].Value > sorted[i].Value { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + + // Take top N + result := make([]map[string]any, 0, n) + for i := 0; i < len(sorted) && i < n; i++ { + result = append(result, map[string]any{ + "name": sorted[i].Key, + "count": sorted[i].Value, + }) + } + return result +} + +type activityCounts struct { + total int + actors map[string]int + resourceKinds map[string]int + changeSources map[string]int +} + +func buildActivityCounts(activities []v1alpha1.Activity) activityCounts { + counts := activityCounts{ + total: len(activities), + actors: make(map[string]int), + resourceKinds: make(map[string]int), + changeSources: make(map[string]int), + } + + for _, activity := range activities { + counts.actors[activity.Spec.Actor.Name]++ + counts.resourceKinds[activity.Spec.Resource.Kind]++ + counts.changeSources[activity.Spec.ChangeSource]++ + } + + return counts +} + +func findNew(baseline, comparison map[string]int) []map[string]any { + var result []map[string]any + for k, v := range comparison { + if _, exists := baseline[k]; !exists { + result = append(result, map[string]any{ + "name": k, + "count": v, + "note": "Not present in baseline", + }) + } + } + return result +} + +func findIncreased(baseline, comparison map[string]int) []map[string]any { + var result []map[string]any + for k, v := range comparison { + if baselineV, exists := baseline[k]; exists && v > baselineV { + changePercent := float64(v-baselineV) / float64(baselineV) * 100 + if changePercent >= 50 { // Only include significant increases + result = append(result, map[string]any{ + "name": k, + "baseline": baselineV, + "comparison": v, + "changePercent": changePercent, + }) + } + } + } + return result +} + +func findDecreased(baseline, comparison map[string]int) []map[string]any { + var result []map[string]any + for k, v := range baseline { + if compV, exists := comparison[k]; exists && compV < v { + changePercent := float64(v-compV) / float64(v) * 100 + if changePercent >= 50 { // Only include significant decreases + result = append(result, map[string]any{ + "name": k, + "baseline": v, + "comparison": compV, + "changePercent": -changePercent, + }) + } + } + } + return result +} + +func absFloat(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/pkg/mcp/tools/tools_test.go b/pkg/mcp/tools/tools_test.go index 44a0039e..544030a1 100644 --- a/pkg/mcp/tools/tools_test.go +++ b/pkg/mcp/tools/tools_test.go @@ -1,1265 +1,1265 @@ -package tools - -import ( - "context" - "encoding/json" - "testing" - "time" - - "github.com/modelcontextprotocol/go-sdk/mcp" - authnv1 "k8s.io/api/authentication/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/watch" - auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - "k8s.io/client-go/rest" - - "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" - activityclient "go.miloapis.com/activity/pkg/client/clientset/versioned/typed/activity/v1alpha1" -) - -// ============================================================================= -// Mock Client Implementation -// ============================================================================= - -type mockActivityV1alpha1Client struct { - auditLogQueries *mockAuditLogQueryInterface - auditLogFacetsQueries *mockAuditLogFacetsQueryInterface - activityQueries *mockActivityQueryInterface - activityFacetQueries *mockActivityFacetQueryInterface - activityPolicies *mockActivityPolicyInterface - policyPreviews *mockPolicyPreviewInterface - activities *mockActivityInterface - eventFacetQueries *mockEventFacetQueryInterface - eventQueries *mockEventQueryInterface - reindexJobs *mockReindexJobInterface -} - -func newMockClient() *mockActivityV1alpha1Client { - return &mockActivityV1alpha1Client{ - auditLogQueries: &mockAuditLogQueryInterface{}, - auditLogFacetsQueries: &mockAuditLogFacetsQueryInterface{}, - activityQueries: &mockActivityQueryInterface{}, - activityFacetQueries: &mockActivityFacetQueryInterface{}, - activityPolicies: &mockActivityPolicyInterface{}, - policyPreviews: &mockPolicyPreviewInterface{}, - activities: &mockActivityInterface{}, - eventFacetQueries: &mockEventFacetQueryInterface{}, - eventQueries: &mockEventQueryInterface{}, - reindexJobs: &mockReindexJobInterface{}, - } -} - -func (m *mockActivityV1alpha1Client) AuditLogQueries() activityclient.AuditLogQueryInterface { - return m.auditLogQueries -} - -func (m *mockActivityV1alpha1Client) AuditLogFacetsQueries() activityclient.AuditLogFacetsQueryInterface { - return m.auditLogFacetsQueries -} - -func (m *mockActivityV1alpha1Client) ActivityQueries() activityclient.ActivityQueryInterface { - return m.activityQueries -} - -func (m *mockActivityV1alpha1Client) ActivityFacetQueries() activityclient.ActivityFacetQueryInterface { - return m.activityFacetQueries -} - -func (m *mockActivityV1alpha1Client) ActivityPolicies() activityclient.ActivityPolicyInterface { - return m.activityPolicies -} - -func (m *mockActivityV1alpha1Client) PolicyPreviews() activityclient.PolicyPreviewInterface { - return m.policyPreviews -} - -func (m *mockActivityV1alpha1Client) Activities(namespace string) activityclient.ActivityInterface { - return m.activities -} - -func (m *mockActivityV1alpha1Client) EventFacetQueries() activityclient.EventFacetQueryInterface { - return m.eventFacetQueries -} - -func (m *mockActivityV1alpha1Client) EventQueries() activityclient.EventQueryInterface { - return m.eventQueries -} - -func (m *mockActivityV1alpha1Client) ReindexJobs() activityclient.ReindexJobInterface { - return m.reindexJobs -} - -func (m *mockActivityV1alpha1Client) RESTClient() rest.Interface { - return nil -} - -// ============================================================================= -// Mock AuditLogQuery Interface -// ============================================================================= - -type mockAuditLogQueryInterface struct { - createFunc func(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) -} - -func (m *mockAuditLogQueryInterface) Create(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) { - if m.createFunc != nil { - return m.createFunc(ctx, query, opts) - } - // Default response - now := metav1.NewMicroTime(time.Now()) - return &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-query"}, - Spec: query.Spec, - Status: v1alpha1.AuditLogQueryStatus{ - Results: []auditv1.Event{ - { - TypeMeta: metav1.TypeMeta{Kind: "Event", APIVersion: "audit.k8s.io/v1"}, - Level: auditv1.LevelRequestResponse, - AuditID: "test-audit-id", - Stage: auditv1.StageResponseComplete, - RequestURI: "/api/v1/namespaces/default/pods", - Verb: "create", - User: authnv1.UserInfo{Username: "alice@example.com", UID: "user-123"}, - ObjectRef: &auditv1.ObjectReference{Resource: "pods", Namespace: "default", Name: "my-pod", APIGroup: "", APIVersion: "v1"}, - ResponseStatus: &metav1.Status{Code: 201}, - RequestReceivedTimestamp: now, - StageTimestamp: now, - }, - }, - Continue: "", - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil -} - -// ============================================================================= -// Mock AuditLogFacetsQuery Interface -// ============================================================================= - -type mockAuditLogFacetsQueryInterface struct { - createFunc func(ctx context.Context, query *v1alpha1.AuditLogFacetsQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogFacetsQuery, error) -} - -func (m *mockAuditLogFacetsQueryInterface) Create(ctx context.Context, query *v1alpha1.AuditLogFacetsQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogFacetsQuery, error) { - if m.createFunc != nil { - return m.createFunc(ctx, query, opts) - } - // Default response - facets := make([]v1alpha1.FacetResult, 0, len(query.Spec.Facets)) - for _, spec := range query.Spec.Facets { - facets = append(facets, v1alpha1.FacetResult{ - Field: spec.Field, - Values: []v1alpha1.FacetValue{ - {Value: "test-value-1", Count: 100}, - {Value: "test-value-2", Count: 50}, - }, - }) - } - return &v1alpha1.AuditLogFacetsQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-facets"}, - Spec: query.Spec, - Status: v1alpha1.AuditLogFacetsQueryStatus{Facets: facets}, - }, nil -} - -// ============================================================================= -// Mock ActivityQuery Interface -// ============================================================================= - -type mockActivityQueryInterface struct { - createFunc func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) -} - -func (m *mockActivityQueryInterface) Create(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { - if m.createFunc != nil { - return m.createFunc(ctx, query, opts) - } - // Default response - return &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-activity-query"}, - Spec: query.Spec, - Status: v1alpha1.ActivityQueryStatus{ - Results: []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{ - Name: "activity-1", - CreationTimestamp: metav1.NewTime(time.Now()), - }, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created HTTP proxy api-gateway", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{APIGroup: "networking.datumapis.com", APIVersion: "v1", Kind: "HTTPProxy", Name: "api-gateway", Namespace: "default"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-123"}, - }, - }, - }, - Continue: "", - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil -} - -// ============================================================================= -// Mock ActivityFacetQuery Interface -// ============================================================================= - -type mockActivityFacetQueryInterface struct { - createFunc func(ctx context.Context, query *v1alpha1.ActivityFacetQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityFacetQuery, error) -} - -func (m *mockActivityFacetQueryInterface) Create(ctx context.Context, query *v1alpha1.ActivityFacetQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityFacetQuery, error) { - if m.createFunc != nil { - return m.createFunc(ctx, query, opts) - } - // Default response - facets := make([]v1alpha1.FacetResult, 0, len(query.Spec.Facets)) - for _, spec := range query.Spec.Facets { - facets = append(facets, v1alpha1.FacetResult{ - Field: spec.Field, - Values: []v1alpha1.FacetValue{ - {Value: "alice@example.com", Count: 42}, - {Value: "bob@example.com", Count: 28}, - }, - }) - } - return &v1alpha1.ActivityFacetQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-activity-facets"}, - Spec: query.Spec, - Status: v1alpha1.ActivityFacetQueryStatus{Facets: facets}, - }, nil -} - -// ============================================================================= -// Mock ActivityPolicy Interface -// ============================================================================= - -type mockActivityPolicyInterface struct { - listFunc func(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityPolicyList, error) -} - -func (m *mockActivityPolicyInterface) Create(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.CreateOptions) (*v1alpha1.ActivityPolicy, error) { - return policy, nil -} - -func (m *mockActivityPolicyInterface) Update(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.UpdateOptions) (*v1alpha1.ActivityPolicy, error) { - return policy, nil -} - -func (m *mockActivityPolicyInterface) UpdateStatus(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.UpdateOptions) (*v1alpha1.ActivityPolicy, error) { - return policy, nil -} - -func (m *mockActivityPolicyInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { - return nil -} - -func (m *mockActivityPolicyInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { - return nil -} - -func (m *mockActivityPolicyInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.ActivityPolicy, error) { - return &v1alpha1.ActivityPolicy{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -func (m *mockActivityPolicyInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityPolicyList, error) { - if m.listFunc != nil { - return m.listFunc(ctx, opts) - } - return &v1alpha1.ActivityPolicyList{ - Items: []v1alpha1.ActivityPolicy{ - { - ObjectMeta: metav1.ObjectMeta{Name: "networking-httpproxy"}, - Spec: v1alpha1.ActivityPolicySpec{ - Resource: v1alpha1.ActivityPolicyResource{APIGroup: "networking.datumapis.com", Kind: "HTTPProxy"}, - AuditRules: []v1alpha1.ActivityPolicyRule{ - {Match: "audit.verb == 'create'", Summary: "{{ actor }} created HTTPProxy"}, - }, - }, - Status: v1alpha1.ActivityPolicyStatus{ - Conditions: []metav1.Condition{ - {Type: "Ready", Status: metav1.ConditionTrue}, - }, - }, - }, - }, - }, nil -} - -func (m *mockActivityPolicyInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return nil, nil -} - -func (m *mockActivityPolicyInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.ActivityPolicy, error) { - return &v1alpha1.ActivityPolicy{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -// ============================================================================= -// Mock PolicyPreview Interface -// ============================================================================= - -type mockPolicyPreviewInterface struct { - createFunc func(ctx context.Context, preview *v1alpha1.PolicyPreview, opts metav1.CreateOptions) (*v1alpha1.PolicyPreview, error) -} - -func (m *mockPolicyPreviewInterface) Create(ctx context.Context, preview *v1alpha1.PolicyPreview, opts metav1.CreateOptions) (*v1alpha1.PolicyPreview, error) { - if m.createFunc != nil { - return m.createFunc(ctx, preview, opts) - } - return &v1alpha1.PolicyPreview{ - ObjectMeta: metav1.ObjectMeta{Name: "test-preview"}, - Spec: preview.Spec, - Status: v1alpha1.PolicyPreviewStatus{ - Results: []v1alpha1.PolicyPreviewInputResult{ - {InputIndex: 0, Matched: true, MatchedRuleIndex: 0, MatchedRuleType: "audit"}, - }, - Activities: []v1alpha1.Activity{ - { - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created HTTPProxy", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{ - Kind: "HTTPProxy", - Name: "my-proxy", - }, - }, - }, - }, - }, - }, nil -} - -// ============================================================================= -// Mock Activity Interface (for namespaced activities) -// ============================================================================= - -type mockActivityInterface struct{} - -func (m *mockActivityInterface) Create(ctx context.Context, activity *v1alpha1.Activity, opts metav1.CreateOptions) (*v1alpha1.Activity, error) { - return activity, nil -} - -func (m *mockActivityInterface) Update(ctx context.Context, activity *v1alpha1.Activity, opts metav1.UpdateOptions) (*v1alpha1.Activity, error) { - return activity, nil -} - -func (m *mockActivityInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { - return nil -} - -func (m *mockActivityInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { - return nil -} - -func (m *mockActivityInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.Activity, error) { - return &v1alpha1.Activity{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -func (m *mockActivityInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityList, error) { - return &v1alpha1.ActivityList{}, nil -} - -func (m *mockActivityInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return nil, nil -} - -func (m *mockActivityInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.Activity, error) { - return &v1alpha1.Activity{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -// ============================================================================= -// Mock EventFacetQuery Interface -// ============================================================================= - -type mockEventFacetQueryInterface struct{} - -func (m *mockEventFacetQueryInterface) Create(ctx context.Context, query *v1alpha1.EventFacetQuery, opts metav1.CreateOptions) (*v1alpha1.EventFacetQuery, error) { - return query, nil -} - -func (m *mockEventFacetQueryInterface) Update(ctx context.Context, query *v1alpha1.EventFacetQuery, opts metav1.UpdateOptions) (*v1alpha1.EventFacetQuery, error) { - return query, nil -} - -func (m *mockEventFacetQueryInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { - return nil -} - -func (m *mockEventFacetQueryInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { - return nil -} - -func (m *mockEventFacetQueryInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.EventFacetQuery, error) { - return &v1alpha1.EventFacetQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - - -func (m *mockEventFacetQueryInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return nil, nil -} - -func (m *mockEventFacetQueryInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.EventFacetQuery, error) { - return &v1alpha1.EventFacetQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -// ============================================================================= -// Mock EventQuery Interface -// ============================================================================= - -type mockEventQueryInterface struct{} - -func (m *mockEventQueryInterface) Create(ctx context.Context, query *v1alpha1.EventQuery, opts metav1.CreateOptions) (*v1alpha1.EventQuery, error) { - return query, nil -} - -type mockReindexJobInterface struct{} - -func (m *mockReindexJobInterface) Create(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.CreateOptions) (*v1alpha1.ReindexJob, error) { - return job, nil -} - -func (m *mockReindexJobInterface) Update(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.UpdateOptions) (*v1alpha1.ReindexJob, error) { - return job, nil -} - -func (m *mockReindexJobInterface) UpdateStatus(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.UpdateOptions) (*v1alpha1.ReindexJob, error) { - return job, nil -} - -func (m *mockReindexJobInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { - return nil -} - -func (m *mockReindexJobInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { - return nil -} - -func (m *mockReindexJobInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.ReindexJob, error) { - return &v1alpha1.ReindexJob{}, nil -} - -func (m *mockReindexJobInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ReindexJobList, error) { - return &v1alpha1.ReindexJobList{}, nil -} - -func (m *mockReindexJobInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return nil, nil -} - -func (m *mockReindexJobInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.ReindexJob, error) { - return &v1alpha1.ReindexJob{}, nil -} - -func (m *mockEventQueryInterface) Update(ctx context.Context, query *v1alpha1.EventQuery, opts metav1.UpdateOptions) (*v1alpha1.EventQuery, error) { - return query, nil -} - -func (m *mockEventQueryInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { - return nil -} - -func (m *mockEventQueryInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { - return nil -} - -func (m *mockEventQueryInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.EventQuery, error) { - return &v1alpha1.EventQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -func (m *mockEventQueryInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.EventQueryList, error) { - return &v1alpha1.EventQueryList{}, nil -} - -func (m *mockEventQueryInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { - return nil, nil -} - -func (m *mockEventQueryInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.EventQuery, error) { - return &v1alpha1.EventQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil -} - -// ============================================================================= -// Test Helper Functions -// ============================================================================= - -func createTestProvider(client *mockActivityV1alpha1Client) *ToolProvider { - return NewToolProviderWithClient(client, "default") -} - -func parseJSONResult(t *testing.T, result *mcp.CallToolResult) map[string]any { - t.Helper() - if result.IsError { - t.Fatalf("Expected success but got error: %v", result.Content) - } - if len(result.Content) == 0 { - t.Fatal("Expected content but got none") - } - textContent, ok := result.Content[0].(*mcp.TextContent) - if !ok { - t.Fatalf("Expected TextContent but got %T", result.Content[0]) - } - - var output map[string]any - if err := json.Unmarshal([]byte(textContent.Text), &output); err != nil { - t.Fatalf("Failed to parse JSON output: %v\nContent: %s", err, textContent.Text) - } - return output -} - -// ============================================================================= -// Tests -// ============================================================================= - -func TestQueryAuditLogs(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := QueryAuditLogsArgs{ - StartTime: "now-7d", - EndTime: "now", - Filter: "verb == 'create'", - Limit: 100, - } - - result, _, err := provider.handleQueryAuditLogs(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["count"].(float64) != 1 { - t.Errorf("Expected count=1, got %v", output["count"]) - } - if output["effectiveStartTime"] != "2024-01-01T00:00:00Z" { - t.Errorf("Expected effectiveStartTime, got %v", output["effectiveStartTime"]) - } - - events := output["events"].([]any) - if len(events) != 1 { - t.Errorf("Expected 1 event, got %d", len(events)) - } - - t.Log("✓ query_audit_logs works correctly") -} - -func TestGetAuditLogFacets(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := GetAuditLogFacetsArgs{ - Fields: []string{"verb", "user.username"}, - StartTime: "now-7d", - EndTime: "now", - Limit: 20, - } - - result, _, err := provider.handleGetAuditLogFacets(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - verbFacets := output["verb"].([]any) - if len(verbFacets) != 2 { - t.Errorf("Expected 2 verb facet values, got %d", len(verbFacets)) - } - - userFacets := output["user.username"].([]any) - if len(userFacets) != 2 { - t.Errorf("Expected 2 user facet values, got %d", len(userFacets)) - } - - t.Log("✓ get_audit_log_facets works correctly") -} - -func TestQueryActivities(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := QueryActivitiesArgs{ - StartTime: "now-7d", - EndTime: "now", - ChangeSource: "human", - Limit: 100, - } - - result, _, err := provider.handleQueryActivities(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["count"].(float64) != 1 { - t.Errorf("Expected count=1, got %v", output["count"]) - } - - activities := output["activities"].([]any) - if len(activities) != 1 { - t.Errorf("Expected 1 activity, got %d", len(activities)) - } - - activity := activities[0].(map[string]any) - if activity["summary"] != "alice created HTTP proxy api-gateway" { - t.Errorf("Expected summary, got %v", activity["summary"]) - } - - t.Log("✓ query_activities works correctly") -} - -func TestGetActivityFacets(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := GetActivityFacetsArgs{ - Fields: []string{"spec.actor.name", "spec.resource.kind"}, - StartTime: "now-7d", - EndTime: "now", - Limit: 20, - } - - result, _, err := provider.handleGetActivityFacets(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - actorFacets := output["spec.actor.name"].([]any) - if len(actorFacets) != 2 { - t.Errorf("Expected 2 actor facet values, got %d", len(actorFacets)) - } - - t.Log("✓ get_activity_facets works correctly") -} - -func TestFindFailedOperations(t *testing.T) { - client := newMockClient() - - // Setup mock to return a failed operation - client.auditLogQueries.createFunc = func(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) { - now := metav1.NewMicroTime(time.Now()) - return &v1alpha1.AuditLogQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-failed-ops"}, - Status: v1alpha1.AuditLogQueryStatus{ - Results: []auditv1.Event{ - { - Verb: "create", - User: authnv1.UserInfo{Username: "alice@example.com"}, - ObjectRef: &auditv1.ObjectReference{Resource: "pods", Name: "bad-pod", Namespace: "default"}, - ResponseStatus: &metav1.Status{Code: 403, Message: "Forbidden"}, - RequestReceivedTimestamp: now, - }, - }, - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil - } - - provider := createTestProvider(client) - - args := FindFailedOperationsArgs{ - StartTime: "now-7d", - Limit: 100, - } - - result, _, err := provider.handleFindFailedOperations(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["count"].(float64) != 1 { - t.Errorf("Expected count=1, got %v", output["count"]) - } - - byStatusCode := output["byStatusCode"].(map[string]any) - if byStatusCode["403"].(float64) != 1 { - t.Errorf("Expected 403 count=1, got %v", byStatusCode["403"]) - } - - t.Log("✓ find_failed_operations works correctly") -} - -func TestGetResourceHistory(t *testing.T) { - client := newMockClient() - - // Setup mock to return activities for the resource - client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { - now := metav1.NewTime(time.Now()) - return &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-history"}, - Status: v1alpha1.ActivityQueryStatus{ - Results: []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created Deployment my-app", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "my-app", Namespace: "default"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "bob updated Deployment my-app", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "bob@example.com"}, - Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "my-app", Namespace: "default"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, - }, - }, - }, - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil - } - - provider := createTestProvider(client) - - args := GetResourceHistoryArgs{ - Name: "my-app", - Kind: "Deployment", - Namespace: "default", - } - - result, _, err := provider.handleGetResourceHistory(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["count"].(float64) != 2 { - t.Errorf("Expected count=2, got %v", output["count"]) - } - - history := output["history"].([]any) - if len(history) != 2 { - t.Errorf("Expected 2 history entries, got %d", len(history)) - } - - t.Log("✓ get_resource_history works correctly") -} - -func TestGetResourceHistoryRequiresName(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := GetResourceHistoryArgs{ - // No name or resourceUID provided - } - - result, _, _ := provider.handleGetResourceHistory(context.Background(), nil, args) - - if !result.IsError { - t.Error("Expected error when neither name nor resourceUID provided") - } - - t.Log("✓ get_resource_history validates required fields") -} - -func TestGetUserActivitySummary(t *testing.T) { - client := newMockClient() - - // Setup mock with user activities - client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { - now := metav1.NewTime(time.Now()) - return &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-user-summary"}, - Status: v1alpha1.ActivityQueryStatus{ - Results: []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created Pod pod-1", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "pod-1"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice updated Deployment deploy-1", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "deploy-1"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, - }, - }, - }, - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil - } - - provider := createTestProvider(client) - - args := GetUserActivitySummaryArgs{ - Username: "alice@example.com", - } - - result, _, err := provider.handleGetUserActivitySummary(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["totalActivities"].(float64) != 2 { - t.Errorf("Expected totalActivities=2, got %v", output["totalActivities"]) - } - - user := output["user"].(map[string]any) - if user["username"] != "alice@example.com" { - t.Errorf("Expected username=alice@example.com, got %v", user["username"]) - } - - t.Log("✓ get_user_activity_summary works correctly") -} - -func TestGetUserActivitySummaryRequiresUser(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := GetUserActivitySummaryArgs{ - // No username or userUID - } - - result, _, _ := provider.handleGetUserActivitySummary(context.Background(), nil, args) - - if !result.IsError { - t.Error("Expected error when neither username nor userUID provided") - } - - t.Log("✓ get_user_activity_summary validates required fields") -} - -func TestGetActivityTimeline(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := GetActivityTimelineArgs{ - StartTime: "now-7d", - EndTime: "now", - BucketSize: "day", - } - - result, _, err := provider.handleGetActivityTimeline(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["bucketSize"] != "day" { - t.Errorf("Expected bucketSize=day, got %v", output["bucketSize"]) - } - - buckets := output["buckets"].([]any) - if len(buckets) == 0 { - t.Error("Expected at least 1 bucket") - } - - t.Log("✓ get_activity_timeline works correctly") -} - -func TestSummarizeRecentActivity(t *testing.T) { - client := newMockClient() - - // Setup mock with varied activity - client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { - now := metav1.NewTime(time.Now()) - return &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-summary"}, - Status: v1alpha1.ActivityQueryStatus{ - Results: []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created Pod my-pod", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, - Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "my-pod"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "controller deleted Pod old-pod", - ChangeSource: "system", - Actor: v1alpha1.ActivityActor{Type: "controller", Name: "system:controller"}, - Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "old-pod", Namespace: "default"}, - Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, - }, - }, - }, - EffectiveStartTime: "2024-01-01T00:00:00Z", - EffectiveEndTime: "2024-01-07T00:00:00Z", - }, - }, nil - } - - provider := createTestProvider(client) - - args := SummarizeRecentActivityArgs{ - StartTime: "now-24h", - TopN: 5, - } - - result, _, err := provider.handleSummarizeRecentActivity(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - if output["totalActivities"].(float64) != 2 { - t.Errorf("Expected totalActivities=2, got %v", output["totalActivities"]) - } - - if output["humanChanges"].(float64) != 1 { - t.Errorf("Expected humanChanges=1, got %v", output["humanChanges"]) - } - - if output["systemChanges"].(float64) != 1 { - t.Errorf("Expected systemChanges=1, got %v", output["systemChanges"]) - } - - highlights := output["highlights"].([]any) - if len(highlights) == 0 { - t.Error("Expected highlights") - } - - t.Log("✓ summarize_recent_activity works correctly") -} - -func TestCompareActivityPeriods(t *testing.T) { - client := newMockClient() - - callCount := 0 - client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { - callCount++ - now := metav1.NewTime(time.Now()) - - var results []v1alpha1.Activity - if callCount == 1 { - // Baseline: 2 activities - results = []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{Name: "baseline-1", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created pod test-pod", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-1"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "baseline-2", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice updated pod test-pod", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-2"}, - }, - }, - } - } else { - // Comparison: 4 activities (100% increase) - results = []v1alpha1.Activity{ - { - ObjectMeta: metav1.ObjectMeta{Name: "compare-1", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice created pod test-pod", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-3"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "compare-2", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "alice updated pod test-pod", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-4"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "compare-3", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "bob created deployment test-deploy", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "bob", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Deployment", APIVersion: "apps/v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-5"}, - }, - }, - { - ObjectMeta: metav1.ObjectMeta{Name: "compare-4", CreationTimestamp: now}, - Spec: v1alpha1.ActivitySpec{ - Summary: "bob updated deployment test-deploy", - ChangeSource: "human", - Actor: v1alpha1.ActivityActor{Name: "bob", Type: "user"}, - Resource: v1alpha1.ActivityResource{Kind: "Deployment", APIVersion: "apps/v1"}, - Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, - Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-6"}, - }, - }, - } - } - - return &v1alpha1.ActivityQuery{ - ObjectMeta: metav1.ObjectMeta{Name: "test-compare"}, - Status: v1alpha1.ActivityQueryStatus{ - Results: results, - EffectiveStartTime: query.Spec.StartTime, - EffectiveEndTime: query.Spec.EndTime, - }, - }, nil - } - - provider := createTestProvider(client) - - args := CompareActivityPeriodsArgs{ - BaselineStart: "now-14d", - BaselineEnd: "now-7d", - ComparisonStart: "now-7d", - ComparisonEnd: "now", - } - - result, _, err := provider.handleCompareActivityPeriods(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - baseline := output["baseline"].(map[string]any) - if baseline["count"].(float64) != 2 { - t.Errorf("Expected baseline count=2, got %v", baseline["count"]) - } - - comparison := output["comparison"].(map[string]any) - if comparison["count"].(float64) != 4 { - t.Errorf("Expected comparison count=4, got %v", comparison["count"]) - } - - changePercent := output["changePercent"].(float64) - if changePercent != 100 { - t.Errorf("Expected changePercent=100, got %v", changePercent) - } - - analysis := output["analysis"].(string) - if analysis == "" { - t.Error("Expected analysis string") - } - - t.Log("✓ compare_activity_periods works correctly") -} - -func TestListActivityPolicies(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := ListActivityPoliciesArgs{} - - result, _, err := provider.handleListActivityPolicies(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - policies := output["policies"].([]any) - if len(policies) != 1 { - t.Errorf("Expected 1 policy, got %d", len(policies)) - } - - policy := policies[0].(map[string]any) - if policy["name"] != "networking-httpproxy" { - t.Errorf("Expected name=networking-httpproxy, got %v", policy["name"]) - } - if policy["status"] != "Ready" { - t.Errorf("Expected status=Ready, got %v", policy["status"]) - } - - t.Log("✓ list_activity_policies works correctly") -} - -func TestListActivityPoliciesWithFilter(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := ListActivityPoliciesArgs{ - Kind: "SomethingElse", // Won't match - } - - result, _, err := provider.handleListActivityPolicies(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - policies := output["policies"].([]any) - if len(policies) != 0 { - t.Errorf("Expected 0 policies after filter, got %d", len(policies)) - } - - t.Log("✓ list_activity_policies filtering works correctly") -} - -func TestPreviewActivityPolicy(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - args := PreviewActivityPolicyArgs{ - Policy: v1alpha1.ActivityPolicySpec{ - Resource: v1alpha1.ActivityPolicyResource{ - APIGroup: "networking.datumapis.com", - Kind: "HTTPProxy", - }, - AuditRules: []v1alpha1.ActivityPolicyRule{ - {Match: "audit.verb == 'create'", Summary: "{{ actor }} created HTTPProxy"}, - }, - }, - Inputs: []v1alpha1.PolicyPreviewInput{ - {Type: "audit"}, - }, - } - - result, _, err := provider.handlePreviewActivityPolicy(context.Background(), nil, args) - if err != nil { - t.Fatalf("Unexpected error: %v", err) - } - - output := parseJSONResult(t, result) - - results := output["results"].([]any) - if len(results) != 1 { - t.Errorf("Expected 1 result, got %d", len(results)) - } - - resultEntry := results[0].(map[string]any) - if resultEntry["matched"] != true { - t.Error("Expected matched=true") - } - - activities := output["activities"].([]any) - if len(activities) != 1 { - t.Errorf("Expected 1 activity, got %d", len(activities)) - } - - t.Log("✓ preview_activity_policy works correctly") -} - -// ============================================================================= -// Test Tool Registration -// ============================================================================= - -func TestRegisterTools(t *testing.T) { - client := newMockClient() - provider := createTestProvider(client) - - server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) - provider.RegisterTools(server) - - t.Log("✓ RegisterTools completed without error") -} - -// ============================================================================= -// Test Helper Functions -// ============================================================================= - -func TestIsSystemUser(t *testing.T) { - tests := []struct { - username string - expected bool - }{ - {"alice@example.com", false}, - {"bob", false}, - {"system:serviceaccount:default:my-sa", true}, - {"system:controller", true}, - {"my-controller", true}, - } - - for _, tc := range tests { - result := isSystemUser(tc.username) - if result != tc.expected { - t.Errorf("isSystemUser(%q) = %v, expected %v", tc.username, result, tc.expected) - } - } - - t.Log("✓ isSystemUser works correctly") -} - -func TestGetTopN(t *testing.T) { - counts := map[string]int{ - "a": 10, - "b": 50, - "c": 30, - "d": 5, - } - - result := getTopN(counts, 2) - - if len(result) != 2 { - t.Errorf("Expected 2 items, got %d", len(result)) - } - - if result[0]["name"] != "b" { - t.Errorf("Expected first item to be 'b', got %v", result[0]["name"]) - } - - if result[1]["name"] != "c" { - t.Errorf("Expected second item to be 'c', got %v", result[1]["name"]) - } - - t.Log("✓ getTopN works correctly") -} - -func TestAbsFloat(t *testing.T) { - if absFloat(-5.0) != 5.0 { - t.Error("absFloat(-5.0) should be 5.0") - } - if absFloat(5.0) != 5.0 { - t.Error("absFloat(5.0) should be 5.0") - } - if absFloat(0) != 0 { - t.Error("absFloat(0) should be 0") - } - - t.Log("✓ absFloat works correctly") -} +package tools + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + authnv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" + "k8s.io/client-go/rest" + + "go.miloapis.com/activity/pkg/apis/activity/v1alpha1" + activityclient "go.miloapis.com/activity/pkg/client/clientset/versioned/typed/activity/v1alpha1" +) + +// ============================================================================= +// Mock Client Implementation +// ============================================================================= + +type mockActivityV1alpha1Client struct { + auditLogQueries *mockAuditLogQueryInterface + auditLogFacetsQueries *mockAuditLogFacetsQueryInterface + activityQueries *mockActivityQueryInterface + activityFacetQueries *mockActivityFacetQueryInterface + activityPolicies *mockActivityPolicyInterface + policyPreviews *mockPolicyPreviewInterface + activities *mockActivityInterface + eventFacetQueries *mockEventFacetQueryInterface + eventQueries *mockEventQueryInterface + reindexJobs *mockReindexJobInterface +} + +func newMockClient() *mockActivityV1alpha1Client { + return &mockActivityV1alpha1Client{ + auditLogQueries: &mockAuditLogQueryInterface{}, + auditLogFacetsQueries: &mockAuditLogFacetsQueryInterface{}, + activityQueries: &mockActivityQueryInterface{}, + activityFacetQueries: &mockActivityFacetQueryInterface{}, + activityPolicies: &mockActivityPolicyInterface{}, + policyPreviews: &mockPolicyPreviewInterface{}, + activities: &mockActivityInterface{}, + eventFacetQueries: &mockEventFacetQueryInterface{}, + eventQueries: &mockEventQueryInterface{}, + reindexJobs: &mockReindexJobInterface{}, + } +} + +func (m *mockActivityV1alpha1Client) AuditLogQueries() activityclient.AuditLogQueryInterface { + return m.auditLogQueries +} + +func (m *mockActivityV1alpha1Client) AuditLogFacetsQueries() activityclient.AuditLogFacetsQueryInterface { + return m.auditLogFacetsQueries +} + +func (m *mockActivityV1alpha1Client) ActivityQueries() activityclient.ActivityQueryInterface { + return m.activityQueries +} + +func (m *mockActivityV1alpha1Client) ActivityFacetQueries() activityclient.ActivityFacetQueryInterface { + return m.activityFacetQueries +} + +func (m *mockActivityV1alpha1Client) ActivityPolicies() activityclient.ActivityPolicyInterface { + return m.activityPolicies +} + +func (m *mockActivityV1alpha1Client) PolicyPreviews() activityclient.PolicyPreviewInterface { + return m.policyPreviews +} + +func (m *mockActivityV1alpha1Client) Activities(namespace string) activityclient.ActivityInterface { + return m.activities +} + +func (m *mockActivityV1alpha1Client) EventFacetQueries() activityclient.EventFacetQueryInterface { + return m.eventFacetQueries +} + +func (m *mockActivityV1alpha1Client) EventQueries() activityclient.EventQueryInterface { + return m.eventQueries +} + +func (m *mockActivityV1alpha1Client) ReindexJobs() activityclient.ReindexJobInterface { + return m.reindexJobs +} + +func (m *mockActivityV1alpha1Client) RESTClient() rest.Interface { + return nil +} + +// ============================================================================= +// Mock AuditLogQuery Interface +// ============================================================================= + +type mockAuditLogQueryInterface struct { + createFunc func(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) +} + +func (m *mockAuditLogQueryInterface) Create(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) { + if m.createFunc != nil { + return m.createFunc(ctx, query, opts) + } + // Default response + now := metav1.NewMicroTime(time.Now()) + return &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-query"}, + Spec: query.Spec, + Status: v1alpha1.AuditLogQueryStatus{ + Results: []auditv1.Event{ + { + TypeMeta: metav1.TypeMeta{Kind: "Event", APIVersion: "audit.k8s.io/v1"}, + Level: auditv1.LevelRequestResponse, + AuditID: "test-audit-id", + Stage: auditv1.StageResponseComplete, + RequestURI: "/api/v1/namespaces/default/pods", + Verb: "create", + User: authnv1.UserInfo{Username: "alice@example.com", UID: "user-123"}, + ObjectRef: &auditv1.ObjectReference{Resource: "pods", Namespace: "default", Name: "my-pod", APIGroup: "", APIVersion: "v1"}, + ResponseStatus: &metav1.Status{Code: 201}, + RequestReceivedTimestamp: now, + StageTimestamp: now, + }, + }, + Continue: "", + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil +} + +// ============================================================================= +// Mock AuditLogFacetsQuery Interface +// ============================================================================= + +type mockAuditLogFacetsQueryInterface struct { + createFunc func(ctx context.Context, query *v1alpha1.AuditLogFacetsQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogFacetsQuery, error) +} + +func (m *mockAuditLogFacetsQueryInterface) Create(ctx context.Context, query *v1alpha1.AuditLogFacetsQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogFacetsQuery, error) { + if m.createFunc != nil { + return m.createFunc(ctx, query, opts) + } + // Default response + facets := make([]v1alpha1.FacetResult, 0, len(query.Spec.Facets)) + for _, spec := range query.Spec.Facets { + facets = append(facets, v1alpha1.FacetResult{ + Field: spec.Field, + Values: []v1alpha1.FacetValue{ + {Value: "test-value-1", Count: 100}, + {Value: "test-value-2", Count: 50}, + }, + }) + } + return &v1alpha1.AuditLogFacetsQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-facets"}, + Spec: query.Spec, + Status: v1alpha1.AuditLogFacetsQueryStatus{Facets: facets}, + }, nil +} + +// ============================================================================= +// Mock ActivityQuery Interface +// ============================================================================= + +type mockActivityQueryInterface struct { + createFunc func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) +} + +func (m *mockActivityQueryInterface) Create(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { + if m.createFunc != nil { + return m.createFunc(ctx, query, opts) + } + // Default response + return &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-activity-query"}, + Spec: query.Spec, + Status: v1alpha1.ActivityQueryStatus{ + Results: []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "activity-1", + CreationTimestamp: metav1.NewTime(time.Now()), + }, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created HTTP proxy api-gateway", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{APIGroup: "networking.datumapis.com", APIVersion: "v1", Kind: "HTTPProxy", Name: "api-gateway", Namespace: "default"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-123"}, + }, + }, + }, + Continue: "", + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil +} + +// ============================================================================= +// Mock ActivityFacetQuery Interface +// ============================================================================= + +type mockActivityFacetQueryInterface struct { + createFunc func(ctx context.Context, query *v1alpha1.ActivityFacetQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityFacetQuery, error) +} + +func (m *mockActivityFacetQueryInterface) Create(ctx context.Context, query *v1alpha1.ActivityFacetQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityFacetQuery, error) { + if m.createFunc != nil { + return m.createFunc(ctx, query, opts) + } + // Default response + facets := make([]v1alpha1.FacetResult, 0, len(query.Spec.Facets)) + for _, spec := range query.Spec.Facets { + facets = append(facets, v1alpha1.FacetResult{ + Field: spec.Field, + Values: []v1alpha1.FacetValue{ + {Value: "alice@example.com", Count: 42}, + {Value: "bob@example.com", Count: 28}, + }, + }) + } + return &v1alpha1.ActivityFacetQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-activity-facets"}, + Spec: query.Spec, + Status: v1alpha1.ActivityFacetQueryStatus{Facets: facets}, + }, nil +} + +// ============================================================================= +// Mock ActivityPolicy Interface +// ============================================================================= + +type mockActivityPolicyInterface struct { + listFunc func(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityPolicyList, error) +} + +func (m *mockActivityPolicyInterface) Create(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.CreateOptions) (*v1alpha1.ActivityPolicy, error) { + return policy, nil +} + +func (m *mockActivityPolicyInterface) Update(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.UpdateOptions) (*v1alpha1.ActivityPolicy, error) { + return policy, nil +} + +func (m *mockActivityPolicyInterface) UpdateStatus(ctx context.Context, policy *v1alpha1.ActivityPolicy, opts metav1.UpdateOptions) (*v1alpha1.ActivityPolicy, error) { + return policy, nil +} + +func (m *mockActivityPolicyInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return nil +} + +func (m *mockActivityPolicyInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return nil +} + +func (m *mockActivityPolicyInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.ActivityPolicy, error) { + return &v1alpha1.ActivityPolicy{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +func (m *mockActivityPolicyInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityPolicyList, error) { + if m.listFunc != nil { + return m.listFunc(ctx, opts) + } + return &v1alpha1.ActivityPolicyList{ + Items: []v1alpha1.ActivityPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Name: "networking-httpproxy"}, + Spec: v1alpha1.ActivityPolicySpec{ + Resource: v1alpha1.ActivityPolicyResource{APIGroup: "networking.datumapis.com", Kind: "HTTPProxy"}, + AuditRules: []v1alpha1.ActivityPolicyRule{ + {Match: "audit.verb == 'create'", Summary: "{{ actor }} created HTTPProxy"}, + }, + }, + Status: v1alpha1.ActivityPolicyStatus{ + Conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue}, + }, + }, + }, + }, + }, nil +} + +func (m *mockActivityPolicyInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m *mockActivityPolicyInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.ActivityPolicy, error) { + return &v1alpha1.ActivityPolicy{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +// ============================================================================= +// Mock PolicyPreview Interface +// ============================================================================= + +type mockPolicyPreviewInterface struct { + createFunc func(ctx context.Context, preview *v1alpha1.PolicyPreview, opts metav1.CreateOptions) (*v1alpha1.PolicyPreview, error) +} + +func (m *mockPolicyPreviewInterface) Create(ctx context.Context, preview *v1alpha1.PolicyPreview, opts metav1.CreateOptions) (*v1alpha1.PolicyPreview, error) { + if m.createFunc != nil { + return m.createFunc(ctx, preview, opts) + } + return &v1alpha1.PolicyPreview{ + ObjectMeta: metav1.ObjectMeta{Name: "test-preview"}, + Spec: preview.Spec, + Status: v1alpha1.PolicyPreviewStatus{ + Results: []v1alpha1.PolicyPreviewInputResult{ + {InputIndex: 0, Matched: true, MatchedRuleIndex: 0, MatchedRuleType: "audit"}, + }, + Activities: []v1alpha1.Activity{ + { + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created HTTPProxy", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{ + Kind: "HTTPProxy", + Name: "my-proxy", + }, + }, + }, + }, + }, + }, nil +} + +// ============================================================================= +// Mock Activity Interface (for namespaced activities) +// ============================================================================= + +type mockActivityInterface struct{} + +func (m *mockActivityInterface) Create(ctx context.Context, activity *v1alpha1.Activity, opts metav1.CreateOptions) (*v1alpha1.Activity, error) { + return activity, nil +} + +func (m *mockActivityInterface) Update(ctx context.Context, activity *v1alpha1.Activity, opts metav1.UpdateOptions) (*v1alpha1.Activity, error) { + return activity, nil +} + +func (m *mockActivityInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return nil +} + +func (m *mockActivityInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return nil +} + +func (m *mockActivityInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.Activity, error) { + return &v1alpha1.Activity{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +func (m *mockActivityInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ActivityList, error) { + return &v1alpha1.ActivityList{}, nil +} + +func (m *mockActivityInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m *mockActivityInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.Activity, error) { + return &v1alpha1.Activity{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +// ============================================================================= +// Mock EventFacetQuery Interface +// ============================================================================= + +type mockEventFacetQueryInterface struct{} + +func (m *mockEventFacetQueryInterface) Create(ctx context.Context, query *v1alpha1.EventFacetQuery, opts metav1.CreateOptions) (*v1alpha1.EventFacetQuery, error) { + return query, nil +} + +func (m *mockEventFacetQueryInterface) Update(ctx context.Context, query *v1alpha1.EventFacetQuery, opts metav1.UpdateOptions) (*v1alpha1.EventFacetQuery, error) { + return query, nil +} + +func (m *mockEventFacetQueryInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return nil +} + +func (m *mockEventFacetQueryInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return nil +} + +func (m *mockEventFacetQueryInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.EventFacetQuery, error) { + return &v1alpha1.EventFacetQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + + +func (m *mockEventFacetQueryInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m *mockEventFacetQueryInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.EventFacetQuery, error) { + return &v1alpha1.EventFacetQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +// ============================================================================= +// Mock EventQuery Interface +// ============================================================================= + +type mockEventQueryInterface struct{} + +func (m *mockEventQueryInterface) Create(ctx context.Context, query *v1alpha1.EventQuery, opts metav1.CreateOptions) (*v1alpha1.EventQuery, error) { + return query, nil +} + +type mockReindexJobInterface struct{} + +func (m *mockReindexJobInterface) Create(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.CreateOptions) (*v1alpha1.ReindexJob, error) { + return job, nil +} + +func (m *mockReindexJobInterface) Update(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.UpdateOptions) (*v1alpha1.ReindexJob, error) { + return job, nil +} + +func (m *mockReindexJobInterface) UpdateStatus(ctx context.Context, job *v1alpha1.ReindexJob, opts metav1.UpdateOptions) (*v1alpha1.ReindexJob, error) { + return job, nil +} + +func (m *mockReindexJobInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return nil +} + +func (m *mockReindexJobInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return nil +} + +func (m *mockReindexJobInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.ReindexJob, error) { + return &v1alpha1.ReindexJob{}, nil +} + +func (m *mockReindexJobInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.ReindexJobList, error) { + return &v1alpha1.ReindexJobList{}, nil +} + +func (m *mockReindexJobInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m *mockReindexJobInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.ReindexJob, error) { + return &v1alpha1.ReindexJob{}, nil +} + +func (m *mockEventQueryInterface) Update(ctx context.Context, query *v1alpha1.EventQuery, opts metav1.UpdateOptions) (*v1alpha1.EventQuery, error) { + return query, nil +} + +func (m *mockEventQueryInterface) Delete(ctx context.Context, name string, opts metav1.DeleteOptions) error { + return nil +} + +func (m *mockEventQueryInterface) DeleteCollection(ctx context.Context, opts metav1.DeleteOptions, listOpts metav1.ListOptions) error { + return nil +} + +func (m *mockEventQueryInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*v1alpha1.EventQuery, error) { + return &v1alpha1.EventQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +func (m *mockEventQueryInterface) List(ctx context.Context, opts metav1.ListOptions) (*v1alpha1.EventQueryList, error) { + return &v1alpha1.EventQueryList{}, nil +} + +func (m *mockEventQueryInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +func (m *mockEventQueryInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts metav1.PatchOptions, subresources ...string) (*v1alpha1.EventQuery, error) { + return &v1alpha1.EventQuery{ObjectMeta: metav1.ObjectMeta{Name: name}}, nil +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func createTestProvider(client *mockActivityV1alpha1Client) *ToolProvider { + return NewToolProviderWithClient(client, "default") +} + +func parseJSONResult(t *testing.T, result *mcp.CallToolResult) map[string]any { + t.Helper() + if result.IsError { + t.Fatalf("Expected success but got error: %v", result.Content) + } + if len(result.Content) == 0 { + t.Fatal("Expected content but got none") + } + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("Expected TextContent but got %T", result.Content[0]) + } + + var output map[string]any + if err := json.Unmarshal([]byte(textContent.Text), &output); err != nil { + t.Fatalf("Failed to parse JSON output: %v\nContent: %s", err, textContent.Text) + } + return output +} + +// ============================================================================= +// Tests +// ============================================================================= + +func TestQueryAuditLogs(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := QueryAuditLogsArgs{ + StartTime: "now-7d", + EndTime: "now", + Filter: "verb == 'create'", + Limit: 100, + } + + result, _, err := provider.handleQueryAuditLogs(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["count"].(float64) != 1 { + t.Errorf("Expected count=1, got %v", output["count"]) + } + if output["effectiveStartTime"] != "2024-01-01T00:00:00Z" { + t.Errorf("Expected effectiveStartTime, got %v", output["effectiveStartTime"]) + } + + events := output["events"].([]any) + if len(events) != 1 { + t.Errorf("Expected 1 event, got %d", len(events)) + } + + t.Log("✓ query_audit_logs works correctly") +} + +func TestGetAuditLogFacets(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := GetAuditLogFacetsArgs{ + Fields: []string{"verb", "user.username"}, + StartTime: "now-7d", + EndTime: "now", + Limit: 20, + } + + result, _, err := provider.handleGetAuditLogFacets(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + verbFacets := output["verb"].([]any) + if len(verbFacets) != 2 { + t.Errorf("Expected 2 verb facet values, got %d", len(verbFacets)) + } + + userFacets := output["user.username"].([]any) + if len(userFacets) != 2 { + t.Errorf("Expected 2 user facet values, got %d", len(userFacets)) + } + + t.Log("✓ get_audit_log_facets works correctly") +} + +func TestQueryActivities(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := QueryActivitiesArgs{ + StartTime: "now-7d", + EndTime: "now", + ChangeSource: "human", + Limit: 100, + } + + result, _, err := provider.handleQueryActivities(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["count"].(float64) != 1 { + t.Errorf("Expected count=1, got %v", output["count"]) + } + + activities := output["activities"].([]any) + if len(activities) != 1 { + t.Errorf("Expected 1 activity, got %d", len(activities)) + } + + activity := activities[0].(map[string]any) + if activity["summary"] != "alice created HTTP proxy api-gateway" { + t.Errorf("Expected summary, got %v", activity["summary"]) + } + + t.Log("✓ query_activities works correctly") +} + +func TestGetActivityFacets(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := GetActivityFacetsArgs{ + Fields: []string{"spec.actor.name", "spec.resource.kind"}, + StartTime: "now-7d", + EndTime: "now", + Limit: 20, + } + + result, _, err := provider.handleGetActivityFacets(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + actorFacets := output["spec.actor.name"].([]any) + if len(actorFacets) != 2 { + t.Errorf("Expected 2 actor facet values, got %d", len(actorFacets)) + } + + t.Log("✓ get_activity_facets works correctly") +} + +func TestFindFailedOperations(t *testing.T) { + client := newMockClient() + + // Setup mock to return a failed operation + client.auditLogQueries.createFunc = func(ctx context.Context, query *v1alpha1.AuditLogQuery, opts metav1.CreateOptions) (*v1alpha1.AuditLogQuery, error) { + now := metav1.NewMicroTime(time.Now()) + return &v1alpha1.AuditLogQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-failed-ops"}, + Status: v1alpha1.AuditLogQueryStatus{ + Results: []auditv1.Event{ + { + Verb: "create", + User: authnv1.UserInfo{Username: "alice@example.com"}, + ObjectRef: &auditv1.ObjectReference{Resource: "pods", Name: "bad-pod", Namespace: "default"}, + ResponseStatus: &metav1.Status{Code: 403, Message: "Forbidden"}, + RequestReceivedTimestamp: now, + }, + }, + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil + } + + provider := createTestProvider(client) + + args := FindFailedOperationsArgs{ + StartTime: "now-7d", + Limit: 100, + } + + result, _, err := provider.handleFindFailedOperations(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["count"].(float64) != 1 { + t.Errorf("Expected count=1, got %v", output["count"]) + } + + byStatusCode := output["byStatusCode"].(map[string]any) + if byStatusCode["403"].(float64) != 1 { + t.Errorf("Expected 403 count=1, got %v", byStatusCode["403"]) + } + + t.Log("✓ find_failed_operations works correctly") +} + +func TestGetResourceHistory(t *testing.T) { + client := newMockClient() + + // Setup mock to return activities for the resource + client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { + now := metav1.NewTime(time.Now()) + return &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-history"}, + Status: v1alpha1.ActivityQueryStatus{ + Results: []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created Deployment my-app", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "my-app", Namespace: "default"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "bob updated Deployment my-app", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "bob@example.com"}, + Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "my-app", Namespace: "default"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, + }, + }, + }, + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil + } + + provider := createTestProvider(client) + + args := GetResourceHistoryArgs{ + Name: "my-app", + Kind: "Deployment", + Namespace: "default", + } + + result, _, err := provider.handleGetResourceHistory(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["count"].(float64) != 2 { + t.Errorf("Expected count=2, got %v", output["count"]) + } + + history := output["history"].([]any) + if len(history) != 2 { + t.Errorf("Expected 2 history entries, got %d", len(history)) + } + + t.Log("✓ get_resource_history works correctly") +} + +func TestGetResourceHistoryRequiresName(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := GetResourceHistoryArgs{ + // No name or resourceUID provided + } + + result, _, _ := provider.handleGetResourceHistory(context.Background(), nil, args) + + if !result.IsError { + t.Error("Expected error when neither name nor resourceUID provided") + } + + t.Log("✓ get_resource_history validates required fields") +} + +func TestGetUserActivitySummary(t *testing.T) { + client := newMockClient() + + // Setup mock with user activities + client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { + now := metav1.NewTime(time.Now()) + return &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-user-summary"}, + Status: v1alpha1.ActivityQueryStatus{ + Results: []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created Pod pod-1", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "pod-1"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice updated Deployment deploy-1", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{APIGroup: "apps", APIVersion: "v1", Kind: "Deployment", Name: "deploy-1"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, + }, + }, + }, + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil + } + + provider := createTestProvider(client) + + args := GetUserActivitySummaryArgs{ + Username: "alice@example.com", + } + + result, _, err := provider.handleGetUserActivitySummary(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["totalActivities"].(float64) != 2 { + t.Errorf("Expected totalActivities=2, got %v", output["totalActivities"]) + } + + user := output["user"].(map[string]any) + if user["username"] != "alice@example.com" { + t.Errorf("Expected username=alice@example.com, got %v", user["username"]) + } + + t.Log("✓ get_user_activity_summary works correctly") +} + +func TestGetUserActivitySummaryRequiresUser(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := GetUserActivitySummaryArgs{ + // No username or userUID + } + + result, _, _ := provider.handleGetUserActivitySummary(context.Background(), nil, args) + + if !result.IsError { + t.Error("Expected error when neither username nor userUID provided") + } + + t.Log("✓ get_user_activity_summary validates required fields") +} + +func TestGetActivityTimeline(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := GetActivityTimelineArgs{ + StartTime: "now-7d", + EndTime: "now", + BucketSize: "day", + } + + result, _, err := provider.handleGetActivityTimeline(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["bucketSize"] != "day" { + t.Errorf("Expected bucketSize=day, got %v", output["bucketSize"]) + } + + buckets := output["buckets"].([]any) + if len(buckets) == 0 { + t.Error("Expected at least 1 bucket") + } + + t.Log("✓ get_activity_timeline works correctly") +} + +func TestSummarizeRecentActivity(t *testing.T) { + client := newMockClient() + + // Setup mock with varied activity + client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { + now := metav1.NewTime(time.Now()) + return &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-summary"}, + Status: v1alpha1.ActivityQueryStatus{ + Results: []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-1", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created Pod my-pod", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Type: "user", Name: "alice@example.com"}, + Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "my-pod"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "activity-2", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "controller deleted Pod old-pod", + ChangeSource: "system", + Actor: v1alpha1.ActivityActor{Type: "controller", Name: "system:controller"}, + Resource: v1alpha1.ActivityResource{APIVersion: "v1", Kind: "Pod", Name: "old-pod", Namespace: "default"}, + Tenant: v1alpha1.ActivityTenant{Type: "organization", Name: "acme"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "audit-2"}, + }, + }, + }, + EffectiveStartTime: "2024-01-01T00:00:00Z", + EffectiveEndTime: "2024-01-07T00:00:00Z", + }, + }, nil + } + + provider := createTestProvider(client) + + args := SummarizeRecentActivityArgs{ + StartTime: "now-24h", + TopN: 5, + } + + result, _, err := provider.handleSummarizeRecentActivity(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + if output["totalActivities"].(float64) != 2 { + t.Errorf("Expected totalActivities=2, got %v", output["totalActivities"]) + } + + if output["humanChanges"].(float64) != 1 { + t.Errorf("Expected humanChanges=1, got %v", output["humanChanges"]) + } + + if output["systemChanges"].(float64) != 1 { + t.Errorf("Expected systemChanges=1, got %v", output["systemChanges"]) + } + + highlights := output["highlights"].([]any) + if len(highlights) == 0 { + t.Error("Expected highlights") + } + + t.Log("✓ summarize_recent_activity works correctly") +} + +func TestCompareActivityPeriods(t *testing.T) { + client := newMockClient() + + callCount := 0 + client.activityQueries.createFunc = func(ctx context.Context, query *v1alpha1.ActivityQuery, opts metav1.CreateOptions) (*v1alpha1.ActivityQuery, error) { + callCount++ + now := metav1.NewTime(time.Now()) + + var results []v1alpha1.Activity + if callCount == 1 { + // Baseline: 2 activities + results = []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{Name: "baseline-1", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created pod test-pod", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-1"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "baseline-2", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice updated pod test-pod", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-2"}, + }, + }, + } + } else { + // Comparison: 4 activities (100% increase) + results = []v1alpha1.Activity{ + { + ObjectMeta: metav1.ObjectMeta{Name: "compare-1", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice created pod test-pod", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-3"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "compare-2", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "alice updated pod test-pod", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "alice", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Pod", APIVersion: "v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-4"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "compare-3", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "bob created deployment test-deploy", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "bob", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Deployment", APIVersion: "apps/v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-5"}, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "compare-4", CreationTimestamp: now}, + Spec: v1alpha1.ActivitySpec{ + Summary: "bob updated deployment test-deploy", + ChangeSource: "human", + Actor: v1alpha1.ActivityActor{Name: "bob", Type: "user"}, + Resource: v1alpha1.ActivityResource{Kind: "Deployment", APIVersion: "apps/v1"}, + Tenant: v1alpha1.ActivityTenant{Type: "global", Name: "default"}, + Origin: v1alpha1.ActivityOrigin{Type: "audit", ID: "test-6"}, + }, + }, + } + } + + return &v1alpha1.ActivityQuery{ + ObjectMeta: metav1.ObjectMeta{Name: "test-compare"}, + Status: v1alpha1.ActivityQueryStatus{ + Results: results, + EffectiveStartTime: query.Spec.StartTime, + EffectiveEndTime: query.Spec.EndTime, + }, + }, nil + } + + provider := createTestProvider(client) + + args := CompareActivityPeriodsArgs{ + BaselineStart: "now-14d", + BaselineEnd: "now-7d", + ComparisonStart: "now-7d", + ComparisonEnd: "now", + } + + result, _, err := provider.handleCompareActivityPeriods(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + baseline := output["baseline"].(map[string]any) + if baseline["count"].(float64) != 2 { + t.Errorf("Expected baseline count=2, got %v", baseline["count"]) + } + + comparison := output["comparison"].(map[string]any) + if comparison["count"].(float64) != 4 { + t.Errorf("Expected comparison count=4, got %v", comparison["count"]) + } + + changePercent := output["changePercent"].(float64) + if changePercent != 100 { + t.Errorf("Expected changePercent=100, got %v", changePercent) + } + + analysis := output["analysis"].(string) + if analysis == "" { + t.Error("Expected analysis string") + } + + t.Log("✓ compare_activity_periods works correctly") +} + +func TestListActivityPolicies(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := ListActivityPoliciesArgs{} + + result, _, err := provider.handleListActivityPolicies(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + policies := output["policies"].([]any) + if len(policies) != 1 { + t.Errorf("Expected 1 policy, got %d", len(policies)) + } + + policy := policies[0].(map[string]any) + if policy["name"] != "networking-httpproxy" { + t.Errorf("Expected name=networking-httpproxy, got %v", policy["name"]) + } + if policy["status"] != "Ready" { + t.Errorf("Expected status=Ready, got %v", policy["status"]) + } + + t.Log("✓ list_activity_policies works correctly") +} + +func TestListActivityPoliciesWithFilter(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := ListActivityPoliciesArgs{ + Kind: "SomethingElse", // Won't match + } + + result, _, err := provider.handleListActivityPolicies(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + policies := output["policies"].([]any) + if len(policies) != 0 { + t.Errorf("Expected 0 policies after filter, got %d", len(policies)) + } + + t.Log("✓ list_activity_policies filtering works correctly") +} + +func TestPreviewActivityPolicy(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + args := PreviewActivityPolicyArgs{ + Policy: v1alpha1.ActivityPolicySpec{ + Resource: v1alpha1.ActivityPolicyResource{ + APIGroup: "networking.datumapis.com", + Kind: "HTTPProxy", + }, + AuditRules: []v1alpha1.ActivityPolicyRule{ + {Match: "audit.verb == 'create'", Summary: "{{ actor }} created HTTPProxy"}, + }, + }, + Inputs: []v1alpha1.PolicyPreviewInput{ + {Type: "audit"}, + }, + } + + result, _, err := provider.handlePreviewActivityPolicy(context.Background(), nil, args) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + output := parseJSONResult(t, result) + + results := output["results"].([]any) + if len(results) != 1 { + t.Errorf("Expected 1 result, got %d", len(results)) + } + + resultEntry := results[0].(map[string]any) + if resultEntry["matched"] != true { + t.Error("Expected matched=true") + } + + activities := output["activities"].([]any) + if len(activities) != 1 { + t.Errorf("Expected 1 activity, got %d", len(activities)) + } + + t.Log("✓ preview_activity_policy works correctly") +} + +// ============================================================================= +// Test Tool Registration +// ============================================================================= + +func TestRegisterTools(t *testing.T) { + client := newMockClient() + provider := createTestProvider(client) + + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) + provider.RegisterTools(server) + + t.Log("✓ RegisterTools completed without error") +} + +// ============================================================================= +// Test Helper Functions +// ============================================================================= + +func TestIsSystemUser(t *testing.T) { + tests := []struct { + username string + expected bool + }{ + {"alice@example.com", false}, + {"bob", false}, + {"system:serviceaccount:default:my-sa", true}, + {"system:controller", true}, + {"my-controller", true}, + } + + for _, tc := range tests { + result := isSystemUser(tc.username) + if result != tc.expected { + t.Errorf("isSystemUser(%q) = %v, expected %v", tc.username, result, tc.expected) + } + } + + t.Log("✓ isSystemUser works correctly") +} + +func TestGetTopN(t *testing.T) { + counts := map[string]int{ + "a": 10, + "b": 50, + "c": 30, + "d": 5, + } + + result := getTopN(counts, 2) + + if len(result) != 2 { + t.Errorf("Expected 2 items, got %d", len(result)) + } + + if result[0]["name"] != "b" { + t.Errorf("Expected first item to be 'b', got %v", result[0]["name"]) + } + + if result[1]["name"] != "c" { + t.Errorf("Expected second item to be 'c', got %v", result[1]["name"]) + } + + t.Log("✓ getTopN works correctly") +} + +func TestAbsFloat(t *testing.T) { + if absFloat(-5.0) != 5.0 { + t.Error("absFloat(-5.0) should be 5.0") + } + if absFloat(5.0) != 5.0 { + t.Error("absFloat(5.0) should be 5.0") + } + if absFloat(0) != 0 { + t.Error("absFloat(0) should be 0") + } + + t.Log("✓ absFloat works correctly") +} diff --git a/ui/Dockerfile b/ui/Dockerfile index 45cd3df9..1f50049e 100644 --- a/ui/Dockerfile +++ b/ui/Dockerfile @@ -1,58 +1,58 @@ -# Build stage -FROM node:20-alpine AS builder - -# Enable corepack for pnpm -RUN corepack enable && corepack prepare pnpm@10.29.3 --activate - -WORKDIR /app - -# Copy workspace and lock files first for better caching -COPY pnpm-workspace.yaml ./ -COPY pnpm-lock.yaml ./ -COPY package.json ./ - -# Copy library config files -COPY tsconfig.json ./ -COPY rollup.config.mjs ./ -COPY tailwind.config.js ./ -COPY postcss.config.js ./ - -# Copy source files for the library -COPY src/ ./src/ - -# Copy example app files -COPY example/package.json ./example/ -COPY example/app/ ./example/app/ -COPY example/public/ ./example/public/ -COPY example/tsconfig.json ./example/ -COPY example/vite.config.ts ./example/ -COPY example/tailwind.config.js ./example/ -COPY example/postcss.config.js ./example/ - -# Install dependencies and build -RUN pnpm install --frozen-lockfile && \ - pnpm --filter @datum-cloud/activity-ui build && \ - pnpm --filter activity-ui-example build && \ - pnpm deploy --filter activity-ui-example --prod --legacy /deploy && \ - cp -r example/build /deploy/build - -# Runtime stage - Node.js for Remix server -FROM node:20-alpine AS runtime - -WORKDIR /app - -# Copy deployed app with all dependencies -COPY --from=builder /deploy ./ - -# Expose port 3000 -EXPOSE 3000 - -# Set production environment -ENV NODE_ENV=production - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget -q --spider http://localhost:3000/health || exit 1 - -# Start the Remix server (use node_modules binary directly to avoid npx issues) -CMD ["node", "./node_modules/@remix-run/serve/dist/cli.js", "./build/server/index.js"] +# Build stage +FROM node:20-alpine AS builder + +# Enable corepack for pnpm +RUN corepack enable && corepack prepare pnpm@10.29.3 --activate + +WORKDIR /app + +# Copy workspace and lock files first for better caching +COPY pnpm-workspace.yaml ./ +COPY pnpm-lock.yaml ./ +COPY package.json ./ + +# Copy library config files +COPY tsconfig.json ./ +COPY rollup.config.mjs ./ +COPY tailwind.config.js ./ +COPY postcss.config.js ./ + +# Copy source files for the library +COPY src/ ./src/ + +# Copy example app files +COPY example/package.json ./example/ +COPY example/app/ ./example/app/ +COPY example/public/ ./example/public/ +COPY example/tsconfig.json ./example/ +COPY example/vite.config.ts ./example/ +COPY example/tailwind.config.js ./example/ +COPY example/postcss.config.js ./example/ + +# Install dependencies and build +RUN pnpm install --frozen-lockfile && \ + pnpm --filter @datum-cloud/activity-ui build && \ + pnpm --filter activity-ui-example build && \ + pnpm deploy --filter activity-ui-example --prod --legacy /deploy && \ + cp -r example/build /deploy/build + +# Runtime stage - Node.js for Remix server +FROM node:20-alpine AS runtime + +WORKDIR /app + +# Copy deployed app with all dependencies +COPY --from=builder /deploy ./ + +# Expose port 3000 +EXPOSE 3000 + +# Set production environment +ENV NODE_ENV=production + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider http://localhost:3000/health || exit 1 + +# Start the Remix server (use node_modules binary directly to avoid npx issues) +CMD ["node", "./node_modules/@remix-run/serve/dist/cli.js", "./build/server/index.js"] diff --git a/ui/README.md b/ui/README.md index 075bee16..92c3f91f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -1,334 +1,334 @@ -# Activity UI - React Components for Kubernetes Audit Logs - -React component library for querying and visualizing Kubernetes audit logs via Activity (`activity.miloapis.com/v1alpha1`). - -## Features - -- 🔍 **FilterBuilder** - Interactive CEL expression builder for audit log queries -- 📊 **AuditEventViewer** - Rich visualization of audit events with expandable details -- 🎯 **AuditLogQueryComponent** - Complete query interface combining filter builder and results viewer -- ⚡ **useAuditLogQuery Hook** - React hook for programmatic query execution -- 🔌 **ActivityApiClient** - Typed API client for Activity -- 📦 **TypeScript Types** - Full type definitions matching the Kubernetes API schema - -## Installation - -```bash -npm install @miloapis/activity-ui -``` - -## Quick Start - -```tsx -import { - AuditLogQueryComponent, - ActivityApiClient, -} from '@miloapis/activity-ui'; -import '@miloapis/activity-ui/dist/styles.css'; - -function App() { - const client = new ActivityApiClient({ - baseUrl: 'https://your-activity-api-server.com', - token: 'your-bearer-token', // Optional - }); - - return ( - console.log('Selected:', event)} - /> - ); -} -``` - -## Components - -### AuditLogQueryComponent - -Complete query interface with filter builder and results viewer. - -```tsx - { - console.log('Event selected:', event); - }} - className="custom-class" -/> -``` - -**Props:** -- `client`: ActivityApiClient instance (required) -- `initialFilter`: Initial CEL filter expression (optional) -- `initialLimit`: Initial result limit (optional, default: 100) -- `onEventSelect`: Callback when an event is clicked (optional) -- `className`: Custom CSS class (optional) - -### FilterBuilder - -Interactive builder for CEL filter expressions. - -```tsx - console.log('Filter:', spec)} - initialFilter='verb == "delete"' - initialLimit={100} -/> -``` - -**Props:** -- `onFilterChange`: Callback when filter/limit changes (required) -- `initialFilter`: Initial filter expression (optional) -- `initialLimit`: Initial limit value (optional) -- `className`: Custom CSS class (optional) - -### AuditEventViewer - -Display and interact with audit events. - -```tsx - console.log(event)} -/> -``` - -**Props:** -- `events`: Array of audit events to display (required) -- `onEventSelect`: Callback when an event is clicked (optional) -- `className`: Custom CSS class (optional) - -## Hooks - -### useAuditLogQuery - -React hook for executing queries programmatically. - -```tsx -import { useAuditLogQuery, ActivityApiClient } from '@miloapis/activity-ui'; - -function MyComponent() { - const client = new ActivityApiClient({ baseUrl: '...' }); - - const { - query, - events, - isLoading, - error, - hasMore, - executeQuery, - loadMore, - reset, - } = useAuditLogQuery({ client }); - - const handleSearch = async () => { - await executeQuery({ - filter: 'verb == "delete"', - limit: 50, - }); - }; - - return ( -
- - - {events.map((event) => ( -
{event.verb} - {event.objectRef?.resource}
- ))} - - {hasMore && ( - - )} -
- ); -} -``` - -## API Client - -### ActivityApiClient - -Client for interacting with the Activity API server. - -```tsx -import { ActivityApiClient } from '@miloapis/activity-ui'; - -const client = new ActivityApiClient({ - baseUrl: 'https://activity-api.example.com', - token: 'your-token', // Optional -}); - -// Create a query -const query = await client.createQuery('my-query', { - filter: 'verb == "delete" && ns == "production"', - limit: 100, -}); - -// Get query results -const result = await client.getQuery('my-query'); -console.log(result.status.results); - -// Paginated query execution -for await (const page of client.executeQueryPaginated({ - filter: 'resource == "secrets"', - limit: 100, -})) { - console.log(`Page with ${page.status?.results?.length} events`); -} -``` - -## CEL Filter Examples - -The filter field accepts CEL (Common Expression Language) expressions: - -```javascript -// Find all delete operations -filter: 'verb == "delete"' - -// Find operations in specific namespaces -filter: 'ns in ["production", "staging"]' - -// Find secret access -filter: 'resource == "secrets" && verb in ["get", "list"]' - -// Find operations by user -filter: 'user.startsWith("system:") && verb == "delete"' - -// Time range filtering -filter: 'timestamp >= timestamp("2024-01-01T00:00:00Z") && timestamp <= timestamp("2024-12-31T23:59:59Z")' - -// Complex queries -filter: 'verb == "delete" && resource in ["secrets", "configmaps"] && ns == "production" && stage == "ResponseComplete"' -``` - -### Available Filter Fields - -- `timestamp` - Event timestamp (time.Time) -- `ns` - Kubernetes namespace (string) -- `verb` - HTTP verb (get, list, create, update, delete, etc.) -- `resource` - Resource type (pods, deployments, etc.) -- `user` - Username who performed the action -- `level` - Audit level (Metadata, Request, RequestResponse) -- `stage` - Event stage (RequestReceived, ResponseStarted, ResponseComplete, Panic) -- `uid` - Event UID -- `requestURI` - The request URI -- `sourceIPs` - Source IP addresses (array) - -## Development - -### Prerequisites - -- Node.js 18+ -- npm or yarn - -### Building the Library - -```bash -# Install dependencies -task ui:install - -# Build the library -task ui:build - -# Watch mode for development -task ui:dev - -# Run type checking -task ui:type-check - -# Run linter -task ui:lint -``` - -### Running the Example App - -```bash -# Start the example application -task ui:start - -# Or use the full command -task ui:example:dev -``` - -The example app will start at [http://localhost:3000](http://localhost:3000). - -### Available Tasks - -```bash -task ui:install # Install dependencies -task ui:build # Build component library -task ui:dev # Build in watch mode -task ui:lint # Lint code -task ui:type-check # Type check -task ui:clean # Clean build artifacts - -# Example app -task ui:start # Start example app (alias for example:dev) -task ui:example:dev # Start example in dev mode -task ui:example:build # Build example for production -task ui:example:preview # Preview production build - -# Combined -task ui:test # Run lint + type-check -task ui:all # Build library and example -``` - -## TypeScript Support - -Full TypeScript support with exported types: - -```tsx -import type { - Event, - AuditLogQuery, - AuditLogQuerySpec, - QueryPhase, - FilterField, -} from '@miloapis/activity-ui'; - -const spec: AuditLogQuerySpec = { - filter: 'verb == "delete"', - limit: 100, -}; - -const handleEvent = (event: Event) => { - console.log(event.verb, event.objectRef?.resource); -}; -``` - -## Styling - -Import the default styles: - -```tsx -import '@miloapis/activity-ui/dist/styles.css'; -``` - -Or customize by overriding CSS variables and classes. See the source CSS for available class names. - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Add tests if applicable -5. Run `task ui:test` to verify -6. Submit a pull request - -## License - -Apache-2.0 - -## Support - -For issues and questions: -- GitHub Issues: https://github.com/datum-cloud/activity/issues -- Documentation: See the main [Activity README](../README.md) +# Activity UI - React Components for Kubernetes Audit Logs + +React component library for querying and visualizing Kubernetes audit logs via Activity (`activity.miloapis.com/v1alpha1`). + +## Features + +- 🔍 **FilterBuilder** - Interactive CEL expression builder for audit log queries +- 📊 **AuditEventViewer** - Rich visualization of audit events with expandable details +- 🎯 **AuditLogQueryComponent** - Complete query interface combining filter builder and results viewer +- ⚡ **useAuditLogQuery Hook** - React hook for programmatic query execution +- 🔌 **ActivityApiClient** - Typed API client for Activity +- 📦 **TypeScript Types** - Full type definitions matching the Kubernetes API schema + +## Installation + +```bash +npm install @miloapis/activity-ui +``` + +## Quick Start + +```tsx +import { + AuditLogQueryComponent, + ActivityApiClient, +} from '@miloapis/activity-ui'; +import '@miloapis/activity-ui/dist/styles.css'; + +function App() { + const client = new ActivityApiClient({ + baseUrl: 'https://your-activity-api-server.com', + token: 'your-bearer-token', // Optional + }); + + return ( + console.log('Selected:', event)} + /> + ); +} +``` + +## Components + +### AuditLogQueryComponent + +Complete query interface with filter builder and results viewer. + +```tsx + { + console.log('Event selected:', event); + }} + className="custom-class" +/> +``` + +**Props:** +- `client`: ActivityApiClient instance (required) +- `initialFilter`: Initial CEL filter expression (optional) +- `initialLimit`: Initial result limit (optional, default: 100) +- `onEventSelect`: Callback when an event is clicked (optional) +- `className`: Custom CSS class (optional) + +### FilterBuilder + +Interactive builder for CEL filter expressions. + +```tsx + console.log('Filter:', spec)} + initialFilter='verb == "delete"' + initialLimit={100} +/> +``` + +**Props:** +- `onFilterChange`: Callback when filter/limit changes (required) +- `initialFilter`: Initial filter expression (optional) +- `initialLimit`: Initial limit value (optional) +- `className`: Custom CSS class (optional) + +### AuditEventViewer + +Display and interact with audit events. + +```tsx + console.log(event)} +/> +``` + +**Props:** +- `events`: Array of audit events to display (required) +- `onEventSelect`: Callback when an event is clicked (optional) +- `className`: Custom CSS class (optional) + +## Hooks + +### useAuditLogQuery + +React hook for executing queries programmatically. + +```tsx +import { useAuditLogQuery, ActivityApiClient } from '@miloapis/activity-ui'; + +function MyComponent() { + const client = new ActivityApiClient({ baseUrl: '...' }); + + const { + query, + events, + isLoading, + error, + hasMore, + executeQuery, + loadMore, + reset, + } = useAuditLogQuery({ client }); + + const handleSearch = async () => { + await executeQuery({ + filter: 'verb == "delete"', + limit: 50, + }); + }; + + return ( +
+ + + {events.map((event) => ( +
{event.verb} - {event.objectRef?.resource}
+ ))} + + {hasMore && ( + + )} +
+ ); +} +``` + +## API Client + +### ActivityApiClient + +Client for interacting with the Activity API server. + +```tsx +import { ActivityApiClient } from '@miloapis/activity-ui'; + +const client = new ActivityApiClient({ + baseUrl: 'https://activity-api.example.com', + token: 'your-token', // Optional +}); + +// Create a query +const query = await client.createQuery('my-query', { + filter: 'verb == "delete" && ns == "production"', + limit: 100, +}); + +// Get query results +const result = await client.getQuery('my-query'); +console.log(result.status.results); + +// Paginated query execution +for await (const page of client.executeQueryPaginated({ + filter: 'resource == "secrets"', + limit: 100, +})) { + console.log(`Page with ${page.status?.results?.length} events`); +} +``` + +## CEL Filter Examples + +The filter field accepts CEL (Common Expression Language) expressions: + +```javascript +// Find all delete operations +filter: 'verb == "delete"' + +// Find operations in specific namespaces +filter: 'ns in ["production", "staging"]' + +// Find secret access +filter: 'resource == "secrets" && verb in ["get", "list"]' + +// Find operations by user +filter: 'user.startsWith("system:") && verb == "delete"' + +// Time range filtering +filter: 'timestamp >= timestamp("2024-01-01T00:00:00Z") && timestamp <= timestamp("2024-12-31T23:59:59Z")' + +// Complex queries +filter: 'verb == "delete" && resource in ["secrets", "configmaps"] && ns == "production" && stage == "ResponseComplete"' +``` + +### Available Filter Fields + +- `timestamp` - Event timestamp (time.Time) +- `ns` - Kubernetes namespace (string) +- `verb` - HTTP verb (get, list, create, update, delete, etc.) +- `resource` - Resource type (pods, deployments, etc.) +- `user` - Username who performed the action +- `level` - Audit level (Metadata, Request, RequestResponse) +- `stage` - Event stage (RequestReceived, ResponseStarted, ResponseComplete, Panic) +- `uid` - Event UID +- `requestURI` - The request URI +- `sourceIPs` - Source IP addresses (array) + +## Development + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Building the Library + +```bash +# Install dependencies +task ui:install + +# Build the library +task ui:build + +# Watch mode for development +task ui:dev + +# Run type checking +task ui:type-check + +# Run linter +task ui:lint +``` + +### Running the Example App + +```bash +# Start the example application +task ui:start + +# Or use the full command +task ui:example:dev +``` + +The example app will start at [http://localhost:3000](http://localhost:3000). + +### Available Tasks + +```bash +task ui:install # Install dependencies +task ui:build # Build component library +task ui:dev # Build in watch mode +task ui:lint # Lint code +task ui:type-check # Type check +task ui:clean # Clean build artifacts + +# Example app +task ui:start # Start example app (alias for example:dev) +task ui:example:dev # Start example in dev mode +task ui:example:build # Build example for production +task ui:example:preview # Preview production build + +# Combined +task ui:test # Run lint + type-check +task ui:all # Build library and example +``` + +## TypeScript Support + +Full TypeScript support with exported types: + +```tsx +import type { + Event, + AuditLogQuery, + AuditLogQuerySpec, + QueryPhase, + FilterField, +} from '@miloapis/activity-ui'; + +const spec: AuditLogQuerySpec = { + filter: 'verb == "delete"', + limit: 100, +}; + +const handleEvent = (event: Event) => { + console.log(event.verb, event.objectRef?.resource); +}; +``` + +## Styling + +Import the default styles: + +```tsx +import '@miloapis/activity-ui/dist/styles.css'; +``` + +Or customize by overriding CSS variables and classes. See the source CSS for available class names. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Add tests if applicable +5. Run `task ui:test` to verify +6. Submit a pull request + +## License + +Apache-2.0 + +## Support + +For issues and questions: +- GitHub Issues: https://github.com/datum-cloud/activity/issues +- Documentation: See the main [Activity README](../README.md) diff --git a/ui/example/app/routes/resource-history.tsx b/ui/example/app/routes/resource-history.tsx index ed45c213..3794aafd 100644 --- a/ui/example/app/routes/resource-history.tsx +++ b/ui/example/app/routes/resource-history.tsx @@ -1,409 +1,409 @@ -import { useState, useEffect, useMemo, useCallback } from "react"; -import { useSearchParams } from "@remix-run/react"; -import { - ResourceHistoryView, - ActivityApiClient, - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, - Input, - Label, - Button, - Combobox, - useFacets, - type Activity, - type ResourceFilter, - type ComboboxOption, - type ActivityFeedFilterState, -} from "@datum-cloud/activity-ui"; -import { AppLayout } from "~/components/AppLayout"; -import { EventDetailModal } from "~/components/EventDetailModal"; - -/** - * Resource History page - displays change history for a specific resource. - * Supports filtering by API Group, Kind, Namespace, Name, or UID. - * Uses the Facets API for typeahead dropdowns with cascading filters. - * - * Deep linking supported via URL search params: - * - ?uid= - Search by UID (takes precedence) - * - ?apiGroup=&kind=&namespace=&name= - Search by attributes - */ -export default function ResourceHistoryPage() { - const [searchParams, setSearchParams] = useSearchParams(); - const [client, setClient] = useState(null); - const [selectedActivity, setSelectedActivity] = useState(null); - - // Read initial values from URL search params - const initialApiGroup = searchParams.get("apiGroup") || ""; - const initialKind = searchParams.get("kind") || ""; - const initialNamespace = searchParams.get("namespace") || ""; - const initialName = searchParams.get("name") || ""; - const initialUid = searchParams.get("uid") || ""; - - // Form state - initialized from URL params - const [apiGroup, setApiGroup] = useState(initialApiGroup); - const [kind, setKind] = useState(initialKind); - const [namespace, setNamespace] = useState(initialNamespace); - const [name, setName] = useState(initialName); - const [uid, setUid] = useState(initialUid); - - // Build filter from URL params if present - const filterFromParams = useMemo((): ResourceFilter | null => { - if (initialUid) { - return { uid: initialUid }; - } - if (initialApiGroup || initialKind || initialNamespace || initialName) { - const filter: ResourceFilter = {}; - if (initialApiGroup) filter.apiGroup = initialApiGroup; - if (initialKind) filter.kind = initialKind; - if (initialNamespace) filter.namespace = initialNamespace; - if (initialName) filter.name = initialName; - return filter; - } - return null; - }, [initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); - - // Submitted filter - initialized from URL params - const [submittedFilter, setSubmittedFilter] = useState(filterFromParams); - - // Sync submitted filter when URL params change (e.g., browser back/forward) - useEffect(() => { - setSubmittedFilter(filterFromParams); - // Also sync form state - setApiGroup(initialApiGroup); - setKind(initialKind); - setNamespace(initialNamespace); - setName(initialName); - setUid(initialUid); - }, [filterFromParams, initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); - - useEffect(() => { - // Check if in production environment - const isProduction = - typeof window !== "undefined" && - window.location.hostname !== "localhost" && - window.location.hostname !== "127.0.0.1"; - - if (isProduction) { - setClient(new ActivityApiClient({ baseUrl: "" })); - } else { - const apiUrl = sessionStorage.getItem("apiUrl") || ""; - const token = sessionStorage.getItem("token") || undefined; - setClient( - new ActivityApiClient({ - baseUrl: apiUrl || "", - token, - }) - ); - } - }, []); - - // Build filter state from current form selections for cascading dropdowns - const currentFilters = useMemo((): ActivityFeedFilterState => { - const filters: ActivityFeedFilterState = {}; - if (apiGroup) filters.apiGroups = [apiGroup]; - if (kind) filters.resourceKinds = [kind]; - if (namespace) filters.resourceNamespaces = [namespace]; - if (name) filters.resourceName = name; - return filters; - }, [apiGroup, kind, namespace, name]); - - // Fetch facets for typeahead dropdowns - filtered by current selections - const { - resourceKinds, - apiGroups, - resourceNamespaces, - isLoading: facetsLoading, - } = useFacets( - client!, - { start: "now-30d" }, - currentFilters // Pass current selections to filter facet results - ); - - // Convert facets to combobox options - const apiGroupOptions: ComboboxOption[] = useMemo(() => - apiGroups - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [apiGroups] - ); - - const kindOptions: ComboboxOption[] = useMemo(() => - resourceKinds - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [resourceKinds] - ); - - const namespaceOptions: ComboboxOption[] = useMemo(() => - resourceNamespaces - .filter((f) => f.value) - .map((f) => ({ - value: f.value, - label: f.value, - count: f.count, - })), - [resourceNamespaces] - ); - - const handleSubmit = useCallback((e: React.FormEvent) => { - e.preventDefault(); - const filter: ResourceFilter = {}; - const params = new URLSearchParams(); - - if (uid.trim()) { - filter.uid = uid.trim(); - params.set("uid", uid.trim()); - } else { - if (apiGroup) { - filter.apiGroup = apiGroup; - params.set("apiGroup", apiGroup); - } - if (kind) { - filter.kind = kind; - params.set("kind", kind); - } - if (namespace) { - filter.namespace = namespace; - params.set("namespace", namespace); - } - if (name.trim()) { - filter.name = name.trim(); - params.set("name", name.trim()); - } - } - - // Only submit if we have at least one filter - if (Object.keys(filter).length > 0) { - setSubmittedFilter(filter); - setSearchParams(params, { replace: false }); - } - }, [uid, apiGroup, kind, namespace, name, setSearchParams]); - - const handleActivityClick = useCallback((activity: Activity) => { - setSelectedActivity(activity); - }, []); - - const handleReset = useCallback(() => { - setSubmittedFilter(null); - setApiGroup(""); - setKind(""); - setNamespace(""); - setName(""); - setUid(""); - // Clear URL params - setSearchParams({}, { replace: true }); - }, [setSearchParams]); - - const hasFormData = apiGroup || kind || namespace || name || uid; - const isUidMode = !!uid; - const isAttributeMode = !!(apiGroup || kind || namespace || name); - - return ( - - {!submittedFilter ? ( - - - Resource History - - Search for a resource to view its change history over time - - - -
- {/* Resource Attributes Section */} -
-

- Search by Resource Attributes -

-
-
- - -
-
- - -
-
- - -
-
- - setName(e.target.value)} - placeholder="e.g., api-gateway" - disabled={isUidMode} - /> -
-
-
- - {/* Divider */} -
-
-
-
-
- - or - -
-
- - {/* UID Section */} -
-

- Search by Resource UID -

-
- - setUid(e.target.value)} - placeholder="e.g., 550e8400-e29b-41d4-a716-446655440000" - className="font-mono" - disabled={isAttributeMode} - /> -

- UID provides exact match. When specified, other filters are ignored. -

-
-
- - - - -
-

- Tips -

-
    -
  • - Dropdowns filter automatically based on other selections -
  • -
  • - Name supports partial matching (e.g., "api" matches "api-gateway") -
  • -
  • - Combine filters to narrow down results (e.g., Kind + Namespace) -
  • -
  • - Find a resource's UID with:{" "} - - kubectl get <kind> <name> -o jsonpath='{"{.metadata.uid}"}' - -
  • -
-
- - - ) : ( -
-
-
-

- Resource History -

-

- {submittedFilter.uid ? ( - UID: {submittedFilter.uid} - ) : ( - - {[ - submittedFilter.kind, - submittedFilter.name, - submittedFilter.namespace && `in ${submittedFilter.namespace}`, - submittedFilter.apiGroup && `(${submittedFilter.apiGroup})`, - ] - .filter(Boolean) - .join(" ")} - - )} -

-
- -
- - {client && ( - - )} -
- )} - - {selectedActivity && ( - setSelectedActivity(null)} - /> - )} - - ); -} +import { useState, useEffect, useMemo, useCallback } from "react"; +import { useSearchParams } from "@remix-run/react"; +import { + ResourceHistoryView, + ActivityApiClient, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + Input, + Label, + Button, + Combobox, + useFacets, + type Activity, + type ResourceFilter, + type ComboboxOption, + type ActivityFeedFilterState, +} from "@datum-cloud/activity-ui"; +import { AppLayout } from "~/components/AppLayout"; +import { EventDetailModal } from "~/components/EventDetailModal"; + +/** + * Resource History page - displays change history for a specific resource. + * Supports filtering by API Group, Kind, Namespace, Name, or UID. + * Uses the Facets API for typeahead dropdowns with cascading filters. + * + * Deep linking supported via URL search params: + * - ?uid= - Search by UID (takes precedence) + * - ?apiGroup=&kind=&namespace=&name= - Search by attributes + */ +export default function ResourceHistoryPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const [client, setClient] = useState(null); + const [selectedActivity, setSelectedActivity] = useState(null); + + // Read initial values from URL search params + const initialApiGroup = searchParams.get("apiGroup") || ""; + const initialKind = searchParams.get("kind") || ""; + const initialNamespace = searchParams.get("namespace") || ""; + const initialName = searchParams.get("name") || ""; + const initialUid = searchParams.get("uid") || ""; + + // Form state - initialized from URL params + const [apiGroup, setApiGroup] = useState(initialApiGroup); + const [kind, setKind] = useState(initialKind); + const [namespace, setNamespace] = useState(initialNamespace); + const [name, setName] = useState(initialName); + const [uid, setUid] = useState(initialUid); + + // Build filter from URL params if present + const filterFromParams = useMemo((): ResourceFilter | null => { + if (initialUid) { + return { uid: initialUid }; + } + if (initialApiGroup || initialKind || initialNamespace || initialName) { + const filter: ResourceFilter = {}; + if (initialApiGroup) filter.apiGroup = initialApiGroup; + if (initialKind) filter.kind = initialKind; + if (initialNamespace) filter.namespace = initialNamespace; + if (initialName) filter.name = initialName; + return filter; + } + return null; + }, [initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); + + // Submitted filter - initialized from URL params + const [submittedFilter, setSubmittedFilter] = useState(filterFromParams); + + // Sync submitted filter when URL params change (e.g., browser back/forward) + useEffect(() => { + setSubmittedFilter(filterFromParams); + // Also sync form state + setApiGroup(initialApiGroup); + setKind(initialKind); + setNamespace(initialNamespace); + setName(initialName); + setUid(initialUid); + }, [filterFromParams, initialApiGroup, initialKind, initialNamespace, initialName, initialUid]); + + useEffect(() => { + // Check if in production environment + const isProduction = + typeof window !== "undefined" && + window.location.hostname !== "localhost" && + window.location.hostname !== "127.0.0.1"; + + if (isProduction) { + setClient(new ActivityApiClient({ baseUrl: "" })); + } else { + const apiUrl = sessionStorage.getItem("apiUrl") || ""; + const token = sessionStorage.getItem("token") || undefined; + setClient( + new ActivityApiClient({ + baseUrl: apiUrl || "", + token, + }) + ); + } + }, []); + + // Build filter state from current form selections for cascading dropdowns + const currentFilters = useMemo((): ActivityFeedFilterState => { + const filters: ActivityFeedFilterState = {}; + if (apiGroup) filters.apiGroups = [apiGroup]; + if (kind) filters.resourceKinds = [kind]; + if (namespace) filters.resourceNamespaces = [namespace]; + if (name) filters.resourceName = name; + return filters; + }, [apiGroup, kind, namespace, name]); + + // Fetch facets for typeahead dropdowns - filtered by current selections + const { + resourceKinds, + apiGroups, + resourceNamespaces, + isLoading: facetsLoading, + } = useFacets( + client!, + { start: "now-30d" }, + currentFilters // Pass current selections to filter facet results + ); + + // Convert facets to combobox options + const apiGroupOptions: ComboboxOption[] = useMemo(() => + apiGroups + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [apiGroups] + ); + + const kindOptions: ComboboxOption[] = useMemo(() => + resourceKinds + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [resourceKinds] + ); + + const namespaceOptions: ComboboxOption[] = useMemo(() => + resourceNamespaces + .filter((f) => f.value) + .map((f) => ({ + value: f.value, + label: f.value, + count: f.count, + })), + [resourceNamespaces] + ); + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + const filter: ResourceFilter = {}; + const params = new URLSearchParams(); + + if (uid.trim()) { + filter.uid = uid.trim(); + params.set("uid", uid.trim()); + } else { + if (apiGroup) { + filter.apiGroup = apiGroup; + params.set("apiGroup", apiGroup); + } + if (kind) { + filter.kind = kind; + params.set("kind", kind); + } + if (namespace) { + filter.namespace = namespace; + params.set("namespace", namespace); + } + if (name.trim()) { + filter.name = name.trim(); + params.set("name", name.trim()); + } + } + + // Only submit if we have at least one filter + if (Object.keys(filter).length > 0) { + setSubmittedFilter(filter); + setSearchParams(params, { replace: false }); + } + }, [uid, apiGroup, kind, namespace, name, setSearchParams]); + + const handleActivityClick = useCallback((activity: Activity) => { + setSelectedActivity(activity); + }, []); + + const handleReset = useCallback(() => { + setSubmittedFilter(null); + setApiGroup(""); + setKind(""); + setNamespace(""); + setName(""); + setUid(""); + // Clear URL params + setSearchParams({}, { replace: true }); + }, [setSearchParams]); + + const hasFormData = apiGroup || kind || namespace || name || uid; + const isUidMode = !!uid; + const isAttributeMode = !!(apiGroup || kind || namespace || name); + + return ( + + {!submittedFilter ? ( + + + Resource History + + Search for a resource to view its change history over time + + + +
+ {/* Resource Attributes Section */} +
+

+ Search by Resource Attributes +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + setName(e.target.value)} + placeholder="e.g., api-gateway" + disabled={isUidMode} + /> +
+
+
+ + {/* Divider */} +
+
+
+
+
+ + or + +
+
+ + {/* UID Section */} +
+

+ Search by Resource UID +

+
+ + setUid(e.target.value)} + placeholder="e.g., 550e8400-e29b-41d4-a716-446655440000" + className="font-mono" + disabled={isAttributeMode} + /> +

+ UID provides exact match. When specified, other filters are ignored. +

+
+
+ + + + +
+

+ Tips +

+
    +
  • + Dropdowns filter automatically based on other selections +
  • +
  • + Name supports partial matching (e.g., "api" matches "api-gateway") +
  • +
  • + Combine filters to narrow down results (e.g., Kind + Namespace) +
  • +
  • + Find a resource's UID with:{" "} + + kubectl get <kind> <name> -o jsonpath='{"{.metadata.uid}"}' + +
  • +
+
+ + + ) : ( +
+
+
+

+ Resource History +

+

+ {submittedFilter.uid ? ( + UID: {submittedFilter.uid} + ) : ( + + {[ + submittedFilter.kind, + submittedFilter.name, + submittedFilter.namespace && `in ${submittedFilter.namespace}`, + submittedFilter.apiGroup && `(${submittedFilter.apiGroup})`, + ] + .filter(Boolean) + .join(" ")} + + )} +

+
+ +
+ + {client && ( + + )} +
+ )} + + {selectedActivity && ( + setSelectedActivity(null)} + /> + )} + + ); +} diff --git a/ui/example/package.json b/ui/example/package.json index f7d195d3..43e5948a 100644 --- a/ui/example/package.json +++ b/ui/example/package.json @@ -1,45 +1,45 @@ -{ - "name": "activity-ui-example", - "version": "0.1.0", - "private": true, - "type": "module", - "sideEffects": false, - "scripts": { - "dev": "remix vite:dev", - "build": "remix vite:build", - "start": "remix-serve ./build/server/index.js", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@datum-cloud/activity-ui": "workspace:*", - "@monaco-editor/react": "^4.7.0", - "@remix-run/node": "^2.15.2", - "@remix-run/react": "^2.15.2", - "@remix-run/serve": "^2.15.2", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "isbot": "^5.1.17", - "js-yaml": "^4.1.1", - "lucide-react": "^0.555.0", - "monaco-editor": "^0.52.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" - }, - "devDependencies": { - "@remix-run/dev": "^2.15.2", - "@tailwindcss/oxide-darwin-arm64": "^4.2.1", - "@tailwindcss/postcss": "^4.1.17", - "@tailwindcss/vite": "^4.2.1", - "@types/js-yaml": "^4.0.9", - "@types/react": "^18.2.45", - "@types/react-dom": "^18.2.18", - "autoprefixer": "^10.4.22", - "lightningcss": "^1.31.1", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.17", - "typescript": "^5.3.3", - "vite": "^5.0.8" - } -} +{ + "name": "activity-ui-example", + "version": "0.1.0", + "private": true, + "type": "module", + "sideEffects": false, + "scripts": { + "dev": "remix vite:dev", + "build": "remix vite:build", + "start": "remix-serve ./build/server/index.js", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@datum-cloud/activity-ui": "workspace:*", + "@monaco-editor/react": "^4.7.0", + "@remix-run/node": "^2.15.2", + "@remix-run/react": "^2.15.2", + "@remix-run/serve": "^2.15.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "isbot": "^5.1.17", + "js-yaml": "^4.1.1", + "lucide-react": "^0.555.0", + "monaco-editor": "^0.52.2", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@remix-run/dev": "^2.15.2", + "@tailwindcss/oxide-darwin-arm64": "^4.2.1", + "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/vite": "^4.2.1", + "@types/js-yaml": "^4.0.9", + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "autoprefixer": "^10.4.22", + "lightningcss": "^1.31.1", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.17", + "typescript": "^5.3.3", + "vite": "^5.0.8" + } +} diff --git a/ui/package.json b/ui/package.json index d4f95343..819e9c54 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,87 +1,87 @@ -{ - "name": "@datum-cloud/activity-ui", - "version": "0.1.0", - "packageManager": "pnpm@10.29.3", - "description": "React components for Kubernetes Activity", - "main": "dist/index.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.esm.js", - "require": "./dist/index.js", - "default": "./dist/index.esm.js" - } - }, - "files": [ - "dist" - ], - "scripts": { - "build": "rollup -c", - "dev": "rollup -c -w", - "lint": "eslint src --ext .ts,.tsx", - "type-check": "tsc --noEmit", - "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui", - "test:e2e:headed": "playwright test --headed", - "test:e2e:integration": "playwright test --config=playwright.integration.config.ts", - "test:e2e:integration:headed": "playwright test --config=playwright.integration.config.ts --headed" - }, - "keywords": [ - "react", - "kubernetes", - "audit-logs", - "activity" - ], - "author": "Milo APIs", - "license": "Apache-2.0", - "peerDependencies": { - "@monaco-editor/react": "^4.6.0", - "@radix-ui/react-checkbox": "^1.0.0", - "@radix-ui/react-dialog": "^1.0.0", - "@radix-ui/react-popover": "^1.0.0", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.0", - "@radix-ui/react-tabs": "^1.0.0", - "@radix-ui/react-tooltip": "^1.0.0", - "cmdk": "^1.0.0", - "monaco-editor": "^0.52.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "devDependencies": { - "@monaco-editor/react": "^4.6.0", - "@playwright/test": "^1.48.0", - "@rollup/plugin-commonjs": "^25.0.7", - "@rollup/plugin-node-resolve": "^15.2.3", - "@rollup/plugin-typescript": "^11.1.6", - "@tailwindcss/postcss": "^4.2.1", - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "@typescript-eslint/eslint-plugin": "^6.15.0", - "@typescript-eslint/parser": "^6.15.0", - "autoprefixer": "^10.4.27", - "eslint": "^8.56.0", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", - "monaco-editor": "^0.52.0", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "rollup": "^4.9.1", - "rollup-plugin-peer-deps-external": "^2.2.4", - "rollup-plugin-postcss": "^4.0.2", - "tailwindcss": "^4.2.1", - "tslib": "^2.6.2", - "typescript": "^5.3.3" - }, - "dependencies": { - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "date-fns": "^3.0.6", - "js-yaml": "^4.1.1", - "lucide-react": "^0.563.0", - "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7" - } -} +{ + "name": "@datum-cloud/activity-ui", + "version": "0.1.0", + "packageManager": "pnpm@10.29.3", + "description": "React components for Kubernetes Activity", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.esm.js", + "require": "./dist/index.js", + "default": "./dist/index.esm.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c", + "dev": "rollup -c -w", + "lint": "eslint src --ext .ts,.tsx", + "type-check": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:integration": "playwright test --config=playwright.integration.config.ts", + "test:e2e:integration:headed": "playwright test --config=playwright.integration.config.ts --headed" + }, + "keywords": [ + "react", + "kubernetes", + "audit-logs", + "activity" + ], + "author": "Milo APIs", + "license": "Apache-2.0", + "peerDependencies": { + "@monaco-editor/react": "^4.6.0", + "@radix-ui/react-checkbox": "^1.0.0", + "@radix-ui/react-dialog": "^1.0.0", + "@radix-ui/react-popover": "^1.0.0", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.0", + "@radix-ui/react-tabs": "^1.0.0", + "@radix-ui/react-tooltip": "^1.0.0", + "cmdk": "^1.0.0", + "monaco-editor": "^0.52.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "devDependencies": { + "@monaco-editor/react": "^4.6.0", + "@playwright/test": "^1.48.0", + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-typescript": "^11.1.6", + "@tailwindcss/postcss": "^4.2.1", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "@typescript-eslint/eslint-plugin": "^6.15.0", + "@typescript-eslint/parser": "^6.15.0", + "autoprefixer": "^10.4.27", + "eslint": "^8.56.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "monaco-editor": "^0.52.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "rollup": "^4.9.1", + "rollup-plugin-peer-deps-external": "^2.2.4", + "rollup-plugin-postcss": "^4.0.2", + "tailwindcss": "^4.2.1", + "tslib": "^2.6.2", + "typescript": "^5.3.3" + }, + "dependencies": { + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^3.0.6", + "js-yaml": "^4.1.1", + "lucide-react": "^0.563.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7" + } +} diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index aa6dd4e2..763b2443 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -1,9504 +1,9504 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@radix-ui/react-checkbox': - specifier: ^1.0.0 - version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-dialog': - specifier: ^1.0.0 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-popover': - specifier: ^1.0.0 - version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-select': - specifier: ^2.0.0 - version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-separator': - specifier: ^1.0.0 - version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tabs': - specifier: ^1.0.0 - version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-tooltip': - specifier: ^1.0.0 - version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - cmdk: - specifier: ^1.0.0 - version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - date-fns: - specifier: ^3.0.6 - version: 3.6.0 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - lucide-react: - specifier: ^0.563.0 - version: 0.563.0(react@19.2.4) - tailwind-merge: - specifier: ^3.4.0 - version: 3.5.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@4.2.1) - devDependencies: - '@monaco-editor/react': - specifier: ^4.6.0 - version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@playwright/test': - specifier: ^1.48.0 - version: 1.58.2 - '@rollup/plugin-commonjs': - specifier: ^25.0.7 - version: 25.0.8(rollup@4.59.0) - '@rollup/plugin-node-resolve': - specifier: ^15.2.3 - version: 15.3.1(rollup@4.59.0) - '@rollup/plugin-typescript': - specifier: ^11.1.6 - version: 11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3) - '@tailwindcss/postcss': - specifier: ^4.2.1 - version: 4.2.1 - '@types/react': - specifier: ^18.0.0 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.0.0 - version: 18.3.7(@types/react@18.3.28) - '@typescript-eslint/eslint-plugin': - specifier: ^6.15.0 - version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/parser': - specifier: ^6.15.0 - version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) - autoprefixer: - specifier: ^10.4.27 - version: 10.4.27(postcss@8.5.6) - eslint: - specifier: ^8.56.0 - version: 8.57.1 - eslint-plugin-react: - specifier: ^7.33.2 - version: 7.37.5(eslint@8.57.1) - eslint-plugin-react-hooks: - specifier: ^4.6.0 - version: 4.6.2(eslint@8.57.1) - monaco-editor: - specifier: ^0.52.0 - version: 0.52.2 - react: - specifier: ^19.0.0 - version: 19.2.4 - react-dom: - specifier: ^19.0.0 - version: 19.2.4(react@19.2.4) - rollup: - specifier: ^4.9.1 - version: 4.59.0 - rollup-plugin-peer-deps-external: - specifier: ^2.2.4 - version: 2.2.4(rollup@4.59.0) - rollup-plugin-postcss: - specifier: ^4.0.2 - version: 4.0.2(postcss@8.5.6) - tailwindcss: - specifier: ^4.2.1 - version: 4.2.1 - tslib: - specifier: ^2.6.2 - version: 2.8.1 - typescript: - specifier: ^5.3.3 - version: 5.9.3 - - example: - dependencies: - '@datum-cloud/activity-ui': - specifier: workspace:* - version: link:.. - '@monaco-editor/react': - specifier: ^4.7.0 - version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@remix-run/node': - specifier: ^2.15.2 - version: 2.17.4(typescript@5.9.3) - '@remix-run/react': - specifier: ^2.15.2 - version: 2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@remix-run/serve': - specifier: ^2.15.2 - version: 2.17.4(typescript@5.9.3) - class-variance-authority: - specifier: ^0.7.1 - version: 0.7.1 - clsx: - specifier: ^2.1.1 - version: 2.1.1 - isbot: - specifier: ^5.1.17 - version: 5.1.35 - js-yaml: - specifier: ^4.1.1 - version: 4.1.1 - lucide-react: - specifier: ^0.555.0 - version: 0.555.0(react@18.3.1) - monaco-editor: - specifier: ^0.52.2 - version: 0.52.2 - react: - specifier: ^18.2.0 - version: 18.3.1 - react-dom: - specifier: ^18.2.0 - version: 18.3.1(react@18.3.1) - tailwind-merge: - specifier: ^3.4.0 - version: 3.5.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@4.2.1) - devDependencies: - '@remix-run/dev': - specifier: ^2.15.2 - version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@remix-run/serve@2.17.4(typescript@5.9.3))(@types/node@25.3.2)(lightningcss@1.31.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1)) - '@tailwindcss/oxide-darwin-arm64': - specifier: ^4.2.1 - version: 4.2.1 - '@tailwindcss/postcss': - specifier: ^4.1.17 - version: 4.2.1 - '@tailwindcss/vite': - specifier: ^4.2.1 - version: 4.2.1(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1)) - '@types/js-yaml': - specifier: ^4.0.9 - version: 4.0.9 - '@types/react': - specifier: ^18.2.45 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.2.18 - version: 18.3.7(@types/react@18.3.28) - autoprefixer: - specifier: ^10.4.22 - version: 10.4.27(postcss@8.5.6) - lightningcss: - specifier: ^1.31.1 - version: 1.31.1 - postcss: - specifier: ^8.5.6 - version: 8.5.6 - tailwindcss: - specifier: ^4.1.17 - version: 4.2.1 - typescript: - specifier: ^5.3.3 - version: 5.9.3 - vite: - specifier: ^5.0.8 - version: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - -packages: - - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} - - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} - engines: {node: '>=6.9.0'} - - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-annotate-as-pure@7.27.3': - resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-create-class-features-plugin@7.28.6': - resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-member-expression-to-functions@7.28.5': - resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-optimise-call-expression@7.27.1': - resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} - engines: {node: '>=6.9.0'} - - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} - engines: {node: '>=6.9.0'} - - '@babel/helper-replace-supers@7.28.6': - resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} - engines: {node: '>=6.9.0'} - - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} - engines: {node: '>=6.9.0'} - - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} - engines: {node: '>=6.9.0'} - - '@babel/helpers@7.28.6': - resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} - engines: {node: '>=6.9.0'} - - '@babel/parser@7.29.0': - resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/plugin-syntax-decorators@7.28.6': - resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-jsx@7.28.6': - resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-syntax-typescript@7.28.6': - resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-modules-commonjs@7.28.6': - resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/plugin-transform-typescript@7.28.6': - resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/preset-typescript@7.28.5': - resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0-0 - - '@babel/runtime@7.28.6': - resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} - engines: {node: '>=6.9.0'} - - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} - engines: {node: '>=6.9.0'} - - '@emotion/hash@0.9.2': - resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} - - '@esbuild/aix-ppc64@0.21.5': - resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.17.6': - resolution: {integrity: sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm64@0.21.5': - resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.17.6': - resolution: {integrity: sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-arm@0.21.5': - resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.17.6': - resolution: {integrity: sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/android-x64@0.21.5': - resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.17.6': - resolution: {integrity: sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-arm64@0.21.5': - resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.17.6': - resolution: {integrity: sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/darwin-x64@0.21.5': - resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.17.6': - resolution: {integrity: sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-arm64@0.21.5': - resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.17.6': - resolution: {integrity: sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.21.5': - resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.17.6': - resolution: {integrity: sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm64@0.21.5': - resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.17.6': - resolution: {integrity: sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-arm@0.21.5': - resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.17.6': - resolution: {integrity: sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-ia32@0.21.5': - resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.17.6': - resolution: {integrity: sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-loong64@0.21.5': - resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.17.6': - resolution: {integrity: sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-mips64el@0.21.5': - resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.17.6': - resolution: {integrity: sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-ppc64@0.21.5': - resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.17.6': - resolution: {integrity: sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-riscv64@0.21.5': - resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.17.6': - resolution: {integrity: sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-s390x@0.21.5': - resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.17.6': - resolution: {integrity: sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/linux-x64@0.21.5': - resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-x64@0.17.6': - resolution: {integrity: sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.21.5': - resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-x64@0.17.6': - resolution: {integrity: sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.21.5': - resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - - '@esbuild/sunos-x64@0.17.6': - resolution: {integrity: sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/sunos-x64@0.21.5': - resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.17.6': - resolution: {integrity: sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-arm64@0.21.5': - resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.17.6': - resolution: {integrity: sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-ia32@0.21.5': - resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.17.6': - resolution: {integrity: sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@esbuild/win32-x64@0.21.5': - resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/eslintrc@2.1.4': - resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@eslint/js@8.57.1': - resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} - - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} - - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} - - '@humanwhocodes/config-array@0.13.0': - resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} - engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/object-schema@2.0.3': - resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@jspm/core@2.1.0': - resolution: {integrity: sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==} - - '@mdx-js/mdx@2.3.0': - resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} - - '@monaco-editor/loader@1.7.0': - resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} - - '@monaco-editor/react@4.7.0': - resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} - peerDependencies: - monaco-editor: '>= 0.25.0 < 1' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@npmcli/fs@3.1.1': - resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@npmcli/git@4.1.0': - resolution: {integrity: sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@npmcli/package-json@4.0.1': - resolution: {integrity: sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@npmcli/promise-spawn@6.0.2': - resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} - - '@playwright/test@1.58.2': - resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} - engines: {node: '>=18'} - hasBin: true - - '@radix-ui/number@1.1.1': - resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} - - '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} - - '@radix-ui/react-arrow@1.1.7': - resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-checkbox@1.3.3': - resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-collection@1.1.7': - resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dialog@1.1.15': - resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-direction@1.1.1': - resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-dismissable-layer@1.1.11': - resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-focus-guards@1.1.3': - resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-focus-scope@1.1.7': - resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-popover@1.1.15': - resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-popper@1.2.8': - resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-portal@1.1.9': - resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-primitive@2.1.4': - resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-roving-focus@1.1.11': - resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-select@2.2.6': - resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-separator@1.1.8': - resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-slot@1.2.4': - resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-tabs@1.1.13': - resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-tooltip@1.2.8': - resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/react-use-callback-ref@1.1.1': - resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-controllable-state@1.2.2': - resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-effect-event@0.0.2': - resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-escape-keydown@1.1.1': - resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-previous@1.1.1': - resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-rect@1.1.1': - resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-use-size@1.1.1': - resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - '@radix-ui/react-visually-hidden@1.2.3': - resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - - '@radix-ui/rect@1.1.1': - resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} - - '@remix-run/dev@2.17.4': - resolution: {integrity: sha512-El7r5W6ErX9KIy27+urbc4SIZnIlVDgTOUqzA7Zbv7caKYrsvgj/Z3i/LPy4VNfv0G1EdawPOrygJgIKT4r2FA==} - engines: {node: '>=18.0.0'} - hasBin: true - peerDependencies: - '@remix-run/react': ^2.17.0 - '@remix-run/serve': ^2.17.0 - typescript: ^5.1.0 - vite: ^5.1.0 || ^6.0.0 - wrangler: ^3.28.2 - peerDependenciesMeta: - '@remix-run/serve': - optional: true - typescript: - optional: true - vite: - optional: true - wrangler: - optional: true - - '@remix-run/express@2.17.4': - resolution: {integrity: sha512-4zZs0L7v2pvAq896zHRLNMhoOKIPXM9qnYdHLbz4mpZUMbNAgQacGazArIrUV3M4g0gRMY0dLrt5CqMNrlBeYg==} - engines: {node: '>=18.0.0'} - peerDependencies: - express: ^4.20.0 - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/node@2.17.4': - resolution: {integrity: sha512-9A29JaYiGHDEmaiQuD1IlO/TrQxnnkj98GpytihU+Nz6yTt6RwzzyMMqTAoasRd1dPD4OeSaSqbwkcim/eE76Q==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/react@2.17.4': - resolution: {integrity: sha512-MeXHacIBoohr9jzec5j/Rmk57xk34korkPDDb0OPHgkdvh20lO5fJoSAcnZfjTIOH+Vsq1ZRQlmvG5PRQ/64Sw==} - engines: {node: '>=18.0.0'} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/router@1.23.2': - resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} - engines: {node: '>=14.0.0'} - - '@remix-run/serve@2.17.4': - resolution: {integrity: sha512-c632agTDib70cytmxMVqSbBMlhFKawcg5048yZZK/qeP2AmUweM7OY6Ivgcmv/pgjLXYOu17UBKhtGU8T5y8cQ==} - engines: {node: '>=18.0.0'} - hasBin: true - - '@remix-run/server-runtime@2.17.4': - resolution: {integrity: sha512-oCsFbPuISgh8KpPKsfBChzjcntvTz5L+ggq9VNYWX8RX3yA7OgQpKspRHOSxb05bw7m0Hx+L1KRHXjf3juKX8w==} - engines: {node: '>=18.0.0'} - peerDependencies: - typescript: ^5.1.0 - peerDependenciesMeta: - typescript: - optional: true - - '@remix-run/web-blob@3.1.0': - resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==} - - '@remix-run/web-fetch@4.4.2': - resolution: {integrity: sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==} - engines: {node: ^10.17 || >=12.3} - - '@remix-run/web-file@3.1.0': - resolution: {integrity: sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ==} - - '@remix-run/web-form-data@3.1.0': - resolution: {integrity: sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A==} - - '@remix-run/web-stream@1.1.0': - resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==} - - '@rollup/plugin-commonjs@25.0.8': - resolution: {integrity: sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.68.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-node-resolve@15.3.1': - resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.78.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/plugin-typescript@11.1.6': - resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.14.0||^3.0.0||^4.0.0 - tslib: '*' - typescript: '>=3.7.0' - peerDependenciesMeta: - rollup: - optional: true - tslib: - optional: true - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} - cpu: [x64] - os: [win32] - - '@tailwindcss/node@4.2.1': - resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - - '@tailwindcss/oxide-android-arm64@4.2.1': - resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.2.1': - resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.2.1': - resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} - engines: {node: '>= 20'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.2.1': - resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} - engines: {node: '>= 20'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} - engines: {node: '>= 20'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} - engines: {node: '>= 20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} - engines: {node: '>= 20'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} - engines: {node: '>= 20'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.2.1': - resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} - engines: {node: '>= 20'} - - '@tailwindcss/postcss@4.2.1': - resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} - - '@tailwindcss/vite@4.2.1': - resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - - '@trysound/sax@0.2.0': - resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} - engines: {node: '>=10.13.0'} - - '@types/acorn@4.0.6': - resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} - - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/hast@2.3.10': - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - - '@types/js-yaml@4.0.9': - resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/mdast@3.0.15': - resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} - - '@types/mdx@2.0.13': - resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@25.3.2': - resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} - - '@types/prop-types@15.7.15': - resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - - '@types/react-dom@18.3.7': - resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} - peerDependencies: - '@types/react': ^18.0.0 - - '@types/react@18.3.28': - resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} - - '@types/resolve@1.20.2': - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} - - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} - - '@typescript-eslint/eslint-plugin@6.21.0': - resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/parser@6.21.0': - resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/scope-manager@6.21.0': - resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} - engines: {node: ^16.0.0 || >=18.0.0} - - '@typescript-eslint/type-utils@6.21.0': - resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/types@6.21.0': - resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} - engines: {node: ^16.0.0 || >=18.0.0} - - '@typescript-eslint/typescript-estree@6.21.0': - resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - '@typescript-eslint/utils@6.21.0': - resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} - engines: {node: ^16.0.0 || >=18.0.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 - - '@typescript-eslint/visitor-keys@6.21.0': - resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} - engines: {node: ^16.0.0 || >=18.0.0} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - '@vanilla-extract/babel-plugin-debug-ids@1.2.2': - resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} - - '@vanilla-extract/css@1.18.0': - resolution: {integrity: sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ==} - - '@vanilla-extract/integration@6.5.0': - resolution: {integrity: sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==} - - '@vanilla-extract/private@1.0.9': - resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} - - '@web3-storage/multipart-parser@1.0.0': - resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} - - '@zxing/text-encoding@0.9.0': - resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} - - abort-controller@3.0.0: - resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} - engines: {node: '>=6.5'} - - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} - engines: {node: '>= 0.6'} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} - engines: {node: '>=0.4.0'} - hasBin: true - - aggregate-error@3.1.0: - resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} - engines: {node: '>=8'} - - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} - - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-hidden@1.2.6: - resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} - engines: {node: '>=10'} - - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array-union@2.1.0: - resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} - engines: {node: '>=8'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - astring@1.9.0: - resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - autoprefixer@10.4.27: - resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - base64-js@1.5.1: - resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - - baseline-browser-mapping@2.10.0: - resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} - engines: {node: '>=6.0.0'} - hasBin: true - - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - - body-parser@1.20.4: - resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - boolbase@1.0.0: - resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - browserify-zlib@0.1.4: - resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} - - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - - buffer-from@1.1.2: - resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - - bytes@3.1.2: - resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} - engines: {node: '>= 0.8'} - - cac@6.7.14: - resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} - engines: {node: '>=8'} - - cacache@17.1.4: - resolution: {integrity: sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - caniuse-api@3.0.0: - resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} - - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} - - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - - chownr@2.0.0: - resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} - engines: {node: '>=10'} - - class-variance-authority@0.7.1: - resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} - - clean-stack@2.2.0: - resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} - engines: {node: '>=6'} - - cli-cursor@3.1.0: - resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} - engines: {node: '>=8'} - - cli-spinners@2.9.2: - resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} - engines: {node: '>=6'} - - clone@1.0.4: - resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} - engines: {node: '>=0.8'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - cmdk@1.1.1: - resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} - peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - colord@2.9.3: - resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} - - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - - commander@7.2.0: - resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} - engines: {node: '>= 10'} - - commondir@1.0.1: - resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} - - compressible@2.0.18: - resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} - engines: {node: '>= 0.6'} - - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} - engines: {node: '>= 0.8.0'} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - concat-with-sourcemaps@1.1.0: - resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} - - confbox@0.1.8: - resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - - confbox@0.2.4: - resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} - - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} - - content-type@1.0.5: - resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} - engines: {node: '>= 0.6'} - - convert-source-map@2.0.0: - resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} - - cookie-signature@1.2.2: - resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} - engines: {node: '>=6.6.0'} - - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} - engines: {node: '>= 0.6'} - - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - css-declaration-sorter@6.4.1: - resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} - engines: {node: ^10 || ^12 || >=14} - peerDependencies: - postcss: ^8.0.9 - - css-select@4.3.0: - resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} - - css-tree@1.1.3: - resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} - engines: {node: '>=8.0.0'} - - css-what@6.2.2: - resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} - engines: {node: '>= 6'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - cssnano-preset-default@5.2.14: - resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - cssnano-utils@3.1.0: - resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - cssnano@5.1.15: - resolution: {integrity: sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - csso@4.2.0: - resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} - engines: {node: '>=8.0.0'} - - csstype@3.2.3: - resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - - data-uri-to-buffer@3.0.1: - resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} - engines: {node: '>= 6'} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - date-fns@3.6.0: - resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - dedent@1.7.1: - resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deep-object-diff@1.1.9: - resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - defaults@1.0.4: - resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} - - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} - - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} - - depd@2.0.0: - resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} - engines: {node: '>= 0.8'} - - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - - diff@5.2.2: - resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} - engines: {node: '>=0.3.1'} - - dir-glob@3.0.1: - resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} - engines: {node: '>=8'} - - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} - - doctrine@3.0.0: - resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} - engines: {node: '>=6.0.0'} - - dom-serializer@1.4.1: - resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} - - domelementtype@2.3.0: - resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} - - domhandler@4.3.1: - resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} - engines: {node: '>= 4'} - - domutils@2.8.0: - resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} - engines: {node: '>=12'} - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - duplexify@3.7.1: - resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} - - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - ee-first@1.1.1: - resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - - electron-to-chromium@1.5.302: - resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - - encodeurl@2.0.0: - resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} - engines: {node: '>= 0.8'} - - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} - engines: {node: '>=10.13.0'} - - entities@2.2.0: - resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} - - err-code@2.0.3: - resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} - - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} - engines: {node: '>= 0.4'} - - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - - es-set-tostringtag@2.1.0: - resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} - engines: {node: '>= 0.4'} - - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - esbuild-plugins-node-modules-polyfill@1.8.1: - resolution: {integrity: sha512-7vxzmyTFDhYUNhjlciMPmp32eUafNIHiXvo8ZD22PzccnxMoGpPnsYn17gSBoFZgpRYNdCJcAWsQ60YVKgKg3A==} - engines: {node: '>=14.0.0'} - peerDependencies: - esbuild: '>=0.14.0 <=0.27.x' - - esbuild@0.17.6: - resolution: {integrity: sha512-TKFRp9TxrJDdRWfSsSERKEovm6v30iHnrjlcGhLBOtReE28Yp1VSBRfO3GTaOFMoxsNerx4TjrhzSuma9ha83Q==} - engines: {node: '>=12'} - hasBin: true - - esbuild@0.21.5: - resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} - engines: {node: '>=12'} - hasBin: true - - escalade@3.2.0: - resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} - engines: {node: '>=6'} - - escape-html@1.0.3: - resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-plugin-react-hooks@4.6.2: - resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} - engines: {node: '>=10'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-scope@7.2.2: - resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint@8.57.1: - resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. - hasBin: true - - espree@9.6.1: - resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-util-attach-comments@2.1.1: - resolution: {integrity: sha512-+5Ba/xGGS6mnwFbXIuQiDPTbuTxuMCooq3arVv7gPZtYpjp+VXH/NkHAP35OOefPhNG/UGqU3vt/LTABwcHX0w==} - - estree-util-build-jsx@2.2.2: - resolution: {integrity: sha512-m56vOXcOBuaF+Igpb9OPAy7f9w9OIkb5yhjsZuaPm7HoGi4oTOQi0h2+yZ+AtKklYFZ+rPC4n0wYCJCEU1ONqg==} - - estree-util-is-identifier-name@1.1.0: - resolution: {integrity: sha512-OVJZ3fGGt9By77Ix9NhaRbzfbDV/2rx9EP7YIDJTmsZSEc5kYn2vWcNccYyahJL2uAQZK2a5Or2i0wtIKTPoRQ==} - - estree-util-is-identifier-name@2.1.0: - resolution: {integrity: sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ==} - - estree-util-to-js@1.2.0: - resolution: {integrity: sha512-IzU74r1PK5IMMGZXUVZbmiu4A1uhiPgW5hm1GjcOfr4ZzHaMPpLNJjR7HjXiIOzi25nZDrgFTobHTkV5Q6ITjA==} - - estree-util-value-to-estree@1.3.0: - resolution: {integrity: sha512-Y+ughcF9jSUJvncXwqRageavjrNPAI+1M/L3BI3PyLp1nmgYTGUXU6t5z1Y7OWuThoDdhPME07bQU+d5LxdJqw==} - engines: {node: '>=12.0.0'} - - estree-util-visit@1.2.1: - resolution: {integrity: sha512-xbgqcrkIVbIG+lI/gzbvd9SGTJL4zqJKBFttUl5pP27KhAjtMKbX/mQXJ7qgyXpMgVy/zvpm0xoQQaGL8OloOw==} - - estree-walker@0.6.1: - resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} - - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - - estree-walker@3.0.3: - resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - etag@1.8.1: - resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} - engines: {node: '>= 0.6'} - - eval@0.1.8: - resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} - engines: {node: '>= 0.8'} - - event-target-shim@5.0.1: - resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} - engines: {node: '>=6'} - - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} - engines: {node: '>=10'} - - exit-hook@2.2.1: - resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} - engines: {node: '>=6'} - - express@4.22.1: - resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} - engines: {node: '>= 0.10.0'} - - exsolve@1.0.8: - resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastq@1.20.1: - resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} - - fault@2.0.1: - resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - - file-entry-cache@6.0.1: - resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} - engines: {node: ^10.12.0 || >=12.0.0} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@3.2.0: - resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} - engines: {node: ^10.12.0 || >=12.0.0} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} - - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - - format@0.2.2: - resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} - engines: {node: '>=0.4.x'} - - forwarded@0.2.0: - resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} - engines: {node: '>= 0.6'} - - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} - - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - - fs-extra@10.1.0: - resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} - engines: {node: '>=12'} - - fs-minipass@2.1.0: - resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} - engines: {node: '>= 8'} - - fs-minipass@3.0.3: - resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - fs.realpath@1.0.0: - resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - - generic-names@4.0.0: - resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} - - gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - - get-port@5.1.1: - resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} - engines: {node: '>=8'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} - engines: {node: '>=10'} - - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - hasBin: true - - glob@7.2.3: - resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - glob@8.1.0: - resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} - engines: {node: '>=12'} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - globals@13.24.0: - resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} - engines: {node: '>=8'} - - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} - - globby@11.1.0: - resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} - engines: {node: '>=10'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - gunzip-maybe@1.4.2: - resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} - hasBin: true - - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - has-tostringtag@1.0.2: - resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - hast-util-to-estree@2.3.3: - resolution: {integrity: sha512-ihhPIUPxN0v0w6M5+IiAZZrn0LH2uZomeWwhn7uP7avZC6TE7lIiEh2yBMPr5+zi1aUCXq6VoYRgs2Bw9xmycQ==} - - hast-util-whitespace@2.0.1: - resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} - - hosted-git-info@6.1.3: - resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - http-errors@2.0.1: - resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} - engines: {node: '>= 0.8'} - - human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} - engines: {node: '>=10.17.0'} - - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} - engines: {node: '>=0.10.0'} - - icss-replace-symbols@1.1.0: - resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} - - icss-utils@5.1.0: - resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - import-cwd@3.0.0: - resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} - engines: {node: '>=8'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - import-from@3.0.0: - resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} - engines: {node: '>=8'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@4.0.0: - resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} - engines: {node: '>=8'} - - inflight@1.0.6: - resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. - - inherits@2.0.4: - resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - - inline-style-parser@0.1.1: - resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - ipaddr.js@1.9.1: - resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} - engines: {node: '>= 0.10'} - - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-arguments@1.2.0: - resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} - engines: {node: '>= 0.4'} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-buffer@2.0.5: - resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} - engines: {node: '>=4'} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - - is-deflate@1.0.0: - resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-gzip@1.0.0: - resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} - engines: {node: '>=0.10.0'} - - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - - is-interactive@1.0.0: - resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} - engines: {node: '>=8'} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-module@1.0.0: - resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-path-inside@3.0.3: - resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} - engines: {node: '>=8'} - - is-plain-obj@3.0.0: - resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} - engines: {node: '>=10'} - - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-reference@1.2.1: - resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} - - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} - - is-unicode-supported@0.1.0: - resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} - engines: {node: '>=10'} - - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} - - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} - - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} - - isbot@5.1.35: - resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} - engines: {node: '>=18'} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - - javascript-stringify@2.1.0: - resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} - - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} - hasBin: true - - js-tokens@4.0.0: - resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} - hasBin: true - - jsesc@3.0.2: - resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} - engines: {node: '>=6'} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-parse-even-better-errors@3.0.2: - resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - - jsonfile@6.2.0: - resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} - - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.31.1: - resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [android] - - lightningcss-darwin-arm64@1.31.1: - resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.31.1: - resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.31.1: - resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.31.1: - resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.31.1: - resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - lightningcss-linux-arm64-musl@1.31.1: - resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - lightningcss-linux-x64-gnu@1.31.1: - resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - lightningcss-linux-x64-musl@1.31.1: - resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - lightningcss-win32-arm64-msvc@1.31.1: - resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.31.1: - resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.31.1: - resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} - engines: {node: '>= 12.0.0'} - - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - - lilconfig@3.1.3: - resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} - engines: {node: '>=14'} - - loader-utils@3.3.1: - resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} - engines: {node: '>= 12.13.0'} - - local-pkg@1.1.2: - resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} - engines: {node: '>=14'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.camelcase@4.3.0: - resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} - - lodash.debounce@4.0.8: - resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} - - lodash.memoize@4.1.2: - resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lodash.uniq@4.5.0: - resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - - log-symbols@4.1.0: - resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} - engines: {node: '>=10'} - - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - - lru-cache@7.18.3: - resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} - engines: {node: '>=12'} - - lucide-react@0.555.0: - resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - lucide-react@0.563.0: - resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 - - magic-string@0.30.21: - resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - - markdown-extensions@1.1.1: - resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} - engines: {node: '>=0.10.0'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mdast-util-definitions@5.1.2: - resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} - - mdast-util-from-markdown@1.3.1: - resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} - - mdast-util-frontmatter@1.0.1: - resolution: {integrity: sha512-JjA2OjxRqAa8wEG8hloD0uTU0kdn8kbtOWpPP94NBkfAlbxn4S8gCGf/9DwFtEeGPXrDcNXdiDjVaRdUFqYokw==} - - mdast-util-mdx-expression@1.3.2: - resolution: {integrity: sha512-xIPmR5ReJDu/DHH1OoIT1HkuybIfRGYRywC+gJtI7qHjCJp/M9jrmBEJW22O8lskDWm562BX2W8TiAwRTb0rKA==} - - mdast-util-mdx-jsx@2.1.4: - resolution: {integrity: sha512-DtMn9CmVhVzZx3f+optVDF8yFgQVt7FghCRNdlIaS3X5Bnym3hZwPbg/XW86vdpKjlc1PVj26SpnLGeJBXD3JA==} - - mdast-util-mdx@2.0.1: - resolution: {integrity: sha512-38w5y+r8nyKlGvNjSEqWrhG0w5PmnRA+wnBvm+ulYCct7nsGYhFVb0lljS9bQav4psDAS1eGkP2LMVcZBi/aqw==} - - mdast-util-mdxjs-esm@1.3.1: - resolution: {integrity: sha512-SXqglS0HrEvSdUEfoXFtcg7DRl7S2cwOXc7jkuusG472Mmjag34DUDeOJUZtl+BVnyeO1frIgVpHlNRWc2gk/w==} - - mdast-util-phrasing@3.0.1: - resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} - - mdast-util-to-hast@12.3.0: - resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==} - - mdast-util-to-markdown@1.5.0: - resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} - - mdast-util-to-string@3.2.0: - resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} - - mdn-data@2.0.14: - resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} - - media-query-parser@2.0.2: - resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} - - media-typer@0.3.0: - resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} - engines: {node: '>= 0.6'} - - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} - - merge-stream@2.0.0: - resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} - engines: {node: '>= 0.6'} - - micromark-core-commonmark@1.1.0: - resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} - - micromark-extension-frontmatter@1.1.1: - resolution: {integrity: sha512-m2UH9a7n3W8VAH9JO9y01APpPKmNNNs71P0RbknEmYSaZU5Ghogv38BYO94AI5Xw6OYfxZRdHZZ2nYjs/Z+SZQ==} - - micromark-extension-mdx-expression@1.0.8: - resolution: {integrity: sha512-zZpeQtc5wfWKdzDsHRBY003H2Smg+PUi2REhqgIhdzAa5xonhP03FcXxqFSerFiNUr5AWmHpaNPQTBVOS4lrXw==} - - micromark-extension-mdx-jsx@1.0.5: - resolution: {integrity: sha512-gPH+9ZdmDflbu19Xkb8+gheqEDqkSpdCEubQyxuz/Hn8DOXiXvrXeikOoBA71+e8Pfi0/UYmU3wW3H58kr7akA==} - - micromark-extension-mdx-md@1.0.1: - resolution: {integrity: sha512-7MSuj2S7xjOQXAjjkbjBsHkMtb+mDGVW6uI2dBL9snOBCbZmoNgDAeZ0nSn9j3T42UE/g2xVNMn18PJxZvkBEA==} - - micromark-extension-mdxjs-esm@1.0.5: - resolution: {integrity: sha512-xNRBw4aoURcyz/S69B19WnZAkWJMxHMT5hE36GtDAyhoyn/8TuAeqjFJQlwk+MKQsUD7b3l7kFX+vlfVWgcX1w==} - - micromark-extension-mdxjs@1.0.1: - resolution: {integrity: sha512-7YA7hF6i5eKOfFUzZ+0z6avRG52GpWR8DL+kN47y3f2KhxbBZMhmxe7auOeaTBrW2DenbbZTf1ea9tA2hDpC2Q==} - - micromark-factory-destination@1.1.0: - resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} - - micromark-factory-label@1.1.0: - resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==} - - micromark-factory-mdx-expression@1.0.9: - resolution: {integrity: sha512-jGIWzSmNfdnkJq05c7b0+Wv0Kfz3NJ3N4cBjnbO4zjXIlxJr+f8lk+5ZmwFvqdAbUy2q6B5rCY//g0QAAaXDWA==} - - micromark-factory-space@1.1.0: - resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} - - micromark-factory-title@1.1.0: - resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==} - - micromark-factory-whitespace@1.1.0: - resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==} - - micromark-util-character@1.2.0: - resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} - - micromark-util-chunked@1.1.0: - resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==} - - micromark-util-classify-character@1.1.0: - resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==} - - micromark-util-combine-extensions@1.1.0: - resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==} - - micromark-util-decode-numeric-character-reference@1.1.0: - resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==} - - micromark-util-decode-string@1.1.0: - resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==} - - micromark-util-encode@1.1.0: - resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} - - micromark-util-events-to-acorn@1.2.3: - resolution: {integrity: sha512-ij4X7Wuc4fED6UoLWkmo0xJQhsktfNh1J0m8g4PbIMPlx+ek/4YdW5mvbye8z/aZvAPUoxgXHrwVlXAPKMRp1w==} - - micromark-util-html-tag-name@1.2.0: - resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} - - micromark-util-normalize-identifier@1.1.0: - resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==} - - micromark-util-resolve-all@1.1.0: - resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==} - - micromark-util-sanitize-uri@1.2.0: - resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==} - - micromark-util-subtokenize@1.1.0: - resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==} - - micromark-util-symbol@1.1.0: - resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} - - micromark-util-types@1.1.0: - resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} - - micromark@3.2.0: - resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-db@1.54.0: - resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} - engines: {node: '>=6'} - - minimatch@3.1.5: - resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} - - minimatch@5.1.9: - resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} - engines: {node: '>=10'} - - minimatch@9.0.3: - resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} - engines: {node: '>=16 || 14 >=14.17'} - - minimatch@9.0.9: - resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} - engines: {node: '>=16 || 14 >=14.17'} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - minipass-collect@1.0.2: - resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} - engines: {node: '>= 8'} - - minipass-flush@1.0.5: - resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} - engines: {node: '>= 8'} - - minipass-pipeline@1.2.4: - resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} - engines: {node: '>=8'} - - minipass@3.3.6: - resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} - engines: {node: '>=8'} - - minipass@5.0.0: - resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} - engines: {node: '>=8'} - - minipass@7.1.3: - resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@2.1.2: - resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} - engines: {node: '>= 8'} - - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - - mkdirp@1.0.4: - resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} - engines: {node: '>=10'} - hasBin: true - - mlly@1.8.0: - resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - - modern-ahocorasick@1.1.0: - resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} - - monaco-editor@0.52.2: - resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} - - morgan@1.10.1: - resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} - engines: {node: '>= 0.8.0'} - - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - - mrmime@1.0.1: - resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} - engines: {node: '>=10'} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} - engines: {node: '>= 0.6'} - - negotiator@0.6.4: - resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} - engines: {node: '>= 0.6'} - - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} - engines: {node: '>= 0.4'} - - node-releases@2.0.27: - resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - - normalize-package-data@5.0.0: - resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - normalize-url@6.1.0: - resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} - engines: {node: '>=10'} - - npm-install-checks@6.3.0: - resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-normalize-package-bin@3.0.1: - resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-package-arg@10.1.0: - resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-pick-manifest@8.0.2: - resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} - engines: {node: '>=8'} - - nth-check@2.1.1: - resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} - - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} - - on-finished@2.3.0: - resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} - engines: {node: '>= 0.8'} - - on-finished@2.4.1: - resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} - engines: {node: '>= 0.8'} - - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} - engines: {node: '>= 0.8'} - - once@1.4.0: - resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} - - onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} - engines: {node: '>=6'} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - ora@5.4.1: - resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} - engines: {node: '>=10'} - - outdent@0.8.0: - resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} - - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} - - p-finally@1.0.0: - resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} - engines: {node: '>=4'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - p-map@4.0.0: - resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} - engines: {node: '>=10'} - - p-queue@6.6.2: - resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} - engines: {node: '>=8'} - - p-timeout@3.2.0: - resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} - engines: {node: '>=8'} - - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - pako@0.2.9: - resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} - - parse-ms@2.1.0: - resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} - engines: {node: '>=6'} - - parseurl@1.3.3: - resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} - engines: {node: '>= 0.8'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-is-absolute@1.0.1: - resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} - engines: {node: '>=0.10.0'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - path-to-regexp@0.1.12: - resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} - - path-type@4.0.0: - resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} - engines: {node: '>=8'} - - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - - pathe@2.0.3: - resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - - peek-stream@1.1.3: - resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} - - periscopic@3.1.0: - resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - pidtree@0.6.0: - resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} - engines: {node: '>=0.10'} - hasBin: true - - pify@5.0.0: - resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} - engines: {node: '>=10'} - - pkg-types@1.3.1: - resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - - pkg-types@2.3.0: - resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} - - playwright-core@1.58.2: - resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.58.2: - resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} - engines: {node: '>=18'} - hasBin: true - - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss-calc@8.2.4: - resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} - peerDependencies: - postcss: ^8.2.2 - - postcss-colormin@5.3.1: - resolution: {integrity: sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-convert-values@5.1.3: - resolution: {integrity: sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-comments@5.1.2: - resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-duplicates@5.1.0: - resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-empty@5.1.1: - resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-discard-overridden@5.1.0: - resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-load-config@3.1.4: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - postcss-merge-longhand@5.1.7: - resolution: {integrity: sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-merge-rules@5.1.4: - resolution: {integrity: sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-font-values@5.1.0: - resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-gradients@5.1.1: - resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-params@5.1.4: - resolution: {integrity: sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-minify-selectors@5.2.1: - resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-modules-extract-imports@3.1.0: - resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-local-by-default@4.2.0: - resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-scope@3.2.1: - resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules-values@4.0.0: - resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} - engines: {node: ^10 || ^12 || >= 14} - peerDependencies: - postcss: ^8.1.0 - - postcss-modules@4.3.1: - resolution: {integrity: sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==} - peerDependencies: - postcss: ^8.0.0 - - postcss-modules@6.0.1: - resolution: {integrity: sha512-zyo2sAkVvuZFFy0gc2+4O+xar5dYlaVy/ebO24KT0ftk/iJevSNyPyQellsBLlnccwh7f6V6Y4GvuKRYToNgpQ==} - peerDependencies: - postcss: ^8.0.0 - - postcss-normalize-charset@5.1.0: - resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-display-values@5.1.0: - resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-positions@5.1.1: - resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-repeat-style@5.1.1: - resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-string@5.1.0: - resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-timing-functions@5.1.0: - resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-unicode@5.1.1: - resolution: {integrity: sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-url@5.1.0: - resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-normalize-whitespace@5.1.1: - resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-ordered-values@5.1.3: - resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-reduce-initial@5.1.2: - resolution: {integrity: sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-reduce-transforms@5.1.0: - resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-selector-parser@7.1.1: - resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} - engines: {node: '>=4'} - - postcss-svgo@5.1.0: - resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-unique-selectors@5.1.1: - resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier@2.8.8: - resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} - engines: {node: '>=10.13.0'} - hasBin: true - - pretty-ms@7.0.1: - resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} - engines: {node: '>=10'} - - proc-log@3.0.0: - resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - - promise-inflight@1.0.1: - resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} - peerDependencies: - bluebird: '*' - peerDependenciesMeta: - bluebird: - optional: true - - promise-retry@2.0.1: - resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} - engines: {node: '>=10'} - - promise.series@0.2.0: - resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} - engines: {node: '>=0.12'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - - property-information@6.5.0: - resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} - - proxy-addr@2.0.7: - resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} - engines: {node: '>= 0.10'} - - pump@2.0.1: - resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} - - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - - pumpify@1.5.1: - resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} - engines: {node: '>=0.6'} - - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} - engines: {node: '>= 0.6'} - - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 - - react-dom@19.2.4: - resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} - peerDependencies: - react: ^19.2.4 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - - react-refresh@0.14.2: - resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} - engines: {node: '>=0.10.0'} - - react-remove-scroll-bar@2.3.8: - resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - - react-remove-scroll@2.7.2: - resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react-router-dom@6.30.3: - resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' - - react-router@6.30.3: - resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} - engines: {node: '>=14.0.0'} - peerDependencies: - react: '>=16.8' - - react-style-singleton@2.2.3: - resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} - - react@19.2.4: - resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} - engines: {node: '>=0.10.0'} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - - readable-stream@3.6.2: - resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} - engines: {node: '>= 6'} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} - - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} - - remark-frontmatter@4.0.1: - resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} - - remark-mdx-frontmatter@1.1.1: - resolution: {integrity: sha512-7teX9DW4tI2WZkXS4DBxneYSY7NHiXl4AKdWDO9LXVweULlCT8OPWsOjLEnMIXViN1j+QcY8mfbq3k0EK6x3uA==} - engines: {node: '>=12.2.0'} - - remark-mdx@2.3.0: - resolution: {integrity: sha512-g53hMkpM0I98MU266IzDFMrTD980gNF3BJnkyFcmN+dD873mQeD5rdMO3Y2X+x8umQfbSE0PcoEDl7ledSA+2g==} - - remark-parse@10.0.2: - resolution: {integrity: sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==} - - remark-rehype@10.1.0: - resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} - - require-like@0.1.2: - resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} - engines: {node: '>= 0.4'} - hasBin: true - - restore-cursor@3.1.0: - resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} - engines: {node: '>=8'} - - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rimraf@3.0.2: - resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported - hasBin: true - - rollup-plugin-peer-deps-external@2.2.4: - resolution: {integrity: sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==} - peerDependencies: - rollup: '*' - - rollup-plugin-postcss@4.0.2: - resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} - engines: {node: '>=10'} - peerDependencies: - postcss: 8.x - - rollup-pluginutils@2.8.2: - resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} - - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safe-buffer@5.2.1: - resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - - safe-identifier@0.4.2: - resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} - - semver@6.3.1: - resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} - hasBin: true - - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} - engines: {node: '>=10'} - hasBin: true - - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} - - set-cookie-parser@2.7.2: - resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} - - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - setprototypeof@1.2.0: - resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} - - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - - slash@3.0.0: - resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} - engines: {node: '>=8'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - source-map-support@0.5.21: - resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - - source-map@0.6.1: - resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} - engines: {node: '>=0.10.0'} - - source-map@0.7.6: - resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} - engines: {node: '>= 12'} - - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - spdx-correct@3.2.0: - resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} - - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@3.0.1: - resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} - - spdx-license-ids@3.0.23: - resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} - - ssri@10.0.6: - resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - stable@0.1.8: - resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} - deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' - - state-local@1.0.7: - resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} - - statuses@2.0.2: - resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} - engines: {node: '>= 0.8'} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} - - stream-shift@1.0.3: - resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} - - stream-slice@0.1.2: - resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} - - string-hash@1.1.3: - resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} - - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} - - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} - - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} - - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} - - string_decoder@1.3.0: - resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.2.0: - resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} - engines: {node: '>=12'} - - strip-bom@3.0.0: - resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} - engines: {node: '>=4'} - - strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} - engines: {node: '>=6'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - style-inject@0.3.0: - resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} - - style-to-object@0.4.4: - resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} - - stylehacks@5.1.1: - resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} - engines: {node: ^10 || ^12 || >=14.0} - peerDependencies: - postcss: ^8.2.15 - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - svgo@2.8.0: - resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} - engines: {node: '>=10.13.0'} - hasBin: true - - tailwind-merge@3.5.0: - resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} - - tailwindcss-animate@1.0.7: - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - - tailwindcss@4.2.1: - resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} - - tapable@2.3.0: - resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} - engines: {node: '>=6'} - - tar-fs@2.1.4: - resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - - tar@6.2.1: - resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} - engines: {node: '>=10'} - deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - - text-table@0.2.0: - resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} - - through2@2.0.5: - resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - toidentifier@1.0.1: - resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} - engines: {node: '>=0.6'} - - toml@3.0.0: - resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} - - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - ts-api-utils@1.4.3: - resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} - engines: {node: '>=16'} - peerDependencies: - typescript: '>=4.2.0' - - tsconfig-paths@4.2.0: - resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} - engines: {node: '>=6'} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - - turbo-stream@2.4.1: - resolution: {integrity: sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==} - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@0.20.2: - resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} - engines: {node: '>=10'} - - type-is@1.6.18: - resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} - engines: {node: '>= 0.6'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - - ufo@1.6.3: - resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - - undici-types@7.18.2: - resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - - undici@6.23.0: - resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} - engines: {node: '>=18.17'} - - unified@10.1.2: - resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} - - unique-filename@3.0.0: - resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - unique-slug@4.0.0: - resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - unist-util-generated@2.0.1: - resolution: {integrity: sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==} - - unist-util-is@5.2.1: - resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} - - unist-util-position-from-estree@1.1.2: - resolution: {integrity: sha512-poZa0eXpS+/XpoQwGwl79UUdea4ol2ZuCYguVaJS4qzIOMDzbqz8a3erUCOmubSZkaOuGamb3tX790iwOIROww==} - - unist-util-position@4.0.4: - resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} - - unist-util-remove-position@4.0.2: - resolution: {integrity: sha512-TkBb0HABNmxzAcfLf4qsIbFbaPDvMO6wa3b3j4VcEzFVaw1LBKwnW4/sRJ/atSLSzoIg41JWEdnE7N6DIhGDGQ==} - - unist-util-stringify-position@3.0.3: - resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} - - unist-util-visit-parents@5.1.3: - resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} - - unist-util-visit@4.1.2: - resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} - - universalify@2.0.1: - resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} - engines: {node: '>= 10.0.0'} - - unpipe@1.0.0: - resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} - engines: {node: '>= 0.8'} - - update-browserslist-db@1.2.3: - resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - use-callback-ref@1.3.3: - resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - use-sidecar@1.1.3: - resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - peerDependenciesMeta: - '@types/react': - optional: true - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - util@0.12.5: - resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} - - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - uvu@0.5.6: - resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} - engines: {node: '>=8'} - hasBin: true - - valibot@1.2.0: - resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} - peerDependencies: - typescript: '>=5' - peerDependenciesMeta: - typescript: - optional: true - - validate-npm-package-license@3.0.4: - resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} - - validate-npm-package-name@5.0.1: - resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - - vary@1.1.2: - resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} - engines: {node: '>= 0.8'} - - vfile-message@3.1.4: - resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} - - vfile@5.3.7: - resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} - - vite-node@1.6.1: - resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - - vite-node@3.2.4: - resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} - engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} - hasBin: true - - vite@5.4.21: - resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - peerDependencies: - '@types/node': ^18.0.0 || >=20.0.0 - less: '*' - lightningcss: ^1.21.0 - sass: '*' - sass-embedded: '*' - stylus: '*' - sugarss: '*' - terser: ^5.4.0 - peerDependenciesMeta: - '@types/node': - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - - wcwidth@1.0.1: - resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} - - web-encoding@1.1.5: - resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} - - web-streams-polyfill@3.3.3: - resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} - engines: {node: '>= 8'} - - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} - - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} - - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} - - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - which@3.0.1: - resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} - engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - wrappy@1.0.2: - resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - - ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - - yallist@4.0.0: - resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - - yaml@2.8.2: - resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} - engines: {node: '>= 14.6'} - hasBin: true - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - -snapshots: - - '@alloc/quick-lru@5.2.0': {} - - '@babel/code-frame@7.29.0': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - - '@babel/compat-data@7.29.0': {} - - '@babel/core@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.28.6 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/generator@7.29.1': - dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.0.2 - - '@babel/helper-annotate-as-pure@7.27.3': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-compilation-targets@7.28.6': - dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - - '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.29.0 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/helper-globals@7.28.0': {} - - '@babel/helper-member-expression-to-functions@7.28.5': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-optimise-call-expression@7.27.1': - dependencies: - '@babel/types': 7.29.0 - - '@babel/helper-plugin-utils@7.28.6': {} - - '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-member-expression-to-functions': 7.28.5 - '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-skip-transparent-expression-wrappers@7.27.1': - dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@babel/helper-string-parser@7.27.1': {} - - '@babel/helper-validator-identifier@7.28.5': {} - - '@babel/helper-validator-option@7.27.1': {} - - '@babel/helpers@7.28.6': - dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - - '@babel/parser@7.29.0': - dependencies: - '@babel/types': 7.29.0 - - '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - - '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - transitivePeerDependencies: - - supports-color - - '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-annotate-as-pure': 7.27.3 - '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - - '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': - dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 - '@babel/helper-validator-option': 7.27.1 - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) - transitivePeerDependencies: - - supports-color - - '@babel/runtime@7.28.6': {} - - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 - - '@babel/traverse@7.29.0': - dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.0 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - '@babel/types@7.29.0': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - - '@emotion/hash@0.9.2': {} - - '@esbuild/aix-ppc64@0.21.5': - optional: true - - '@esbuild/android-arm64@0.17.6': - optional: true - - '@esbuild/android-arm64@0.21.5': - optional: true - - '@esbuild/android-arm@0.17.6': - optional: true - - '@esbuild/android-arm@0.21.5': - optional: true - - '@esbuild/android-x64@0.17.6': - optional: true - - '@esbuild/android-x64@0.21.5': - optional: true - - '@esbuild/darwin-arm64@0.17.6': - optional: true - - '@esbuild/darwin-arm64@0.21.5': - optional: true - - '@esbuild/darwin-x64@0.17.6': - optional: true - - '@esbuild/darwin-x64@0.21.5': - optional: true - - '@esbuild/freebsd-arm64@0.17.6': - optional: true - - '@esbuild/freebsd-arm64@0.21.5': - optional: true - - '@esbuild/freebsd-x64@0.17.6': - optional: true - - '@esbuild/freebsd-x64@0.21.5': - optional: true - - '@esbuild/linux-arm64@0.17.6': - optional: true - - '@esbuild/linux-arm64@0.21.5': - optional: true - - '@esbuild/linux-arm@0.17.6': - optional: true - - '@esbuild/linux-arm@0.21.5': - optional: true - - '@esbuild/linux-ia32@0.17.6': - optional: true - - '@esbuild/linux-ia32@0.21.5': - optional: true - - '@esbuild/linux-loong64@0.17.6': - optional: true - - '@esbuild/linux-loong64@0.21.5': - optional: true - - '@esbuild/linux-mips64el@0.17.6': - optional: true - - '@esbuild/linux-mips64el@0.21.5': - optional: true - - '@esbuild/linux-ppc64@0.17.6': - optional: true - - '@esbuild/linux-ppc64@0.21.5': - optional: true - - '@esbuild/linux-riscv64@0.17.6': - optional: true - - '@esbuild/linux-riscv64@0.21.5': - optional: true - - '@esbuild/linux-s390x@0.17.6': - optional: true - - '@esbuild/linux-s390x@0.21.5': - optional: true - - '@esbuild/linux-x64@0.17.6': - optional: true - - '@esbuild/linux-x64@0.21.5': - optional: true - - '@esbuild/netbsd-x64@0.17.6': - optional: true - - '@esbuild/netbsd-x64@0.21.5': - optional: true - - '@esbuild/openbsd-x64@0.17.6': - optional: true - - '@esbuild/openbsd-x64@0.21.5': - optional: true - - '@esbuild/sunos-x64@0.17.6': - optional: true - - '@esbuild/sunos-x64@0.21.5': - optional: true - - '@esbuild/win32-arm64@0.17.6': - optional: true - - '@esbuild/win32-arm64@0.21.5': - optional: true - - '@esbuild/win32-ia32@0.17.6': - optional: true - - '@esbuild/win32-ia32@0.21.5': - optional: true - - '@esbuild/win32-x64@0.17.6': - optional: true - - '@esbuild/win32-x64@0.21.5': - optional: true - - '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': - dependencies: - eslint: 8.57.1 - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} - - '@eslint/eslintrc@2.1.4': - dependencies: - ajv: 6.14.0 - debug: 4.4.3 - espree: 9.6.1 - globals: 13.24.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.5 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@8.57.1': {} - - '@floating-ui/core@1.7.4': - dependencies: - '@floating-ui/utils': 0.2.10 - - '@floating-ui/dom@1.7.5': - dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 - - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/dom': 1.7.5 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@floating-ui/utils@0.2.10': {} - - '@humanwhocodes/config-array@0.13.0': - dependencies: - '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 - minimatch: 3.1.5 - transitivePeerDependencies: - - supports-color - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/object-schema@2.0.3': {} - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.2.0 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@jspm/core@2.1.0': {} - - '@mdx-js/mdx@2.3.0': - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/mdx': 2.0.13 - estree-util-build-jsx: 2.2.2 - estree-util-is-identifier-name: 2.1.0 - estree-util-to-js: 1.2.0 - estree-walker: 3.0.3 - hast-util-to-estree: 2.3.3 - markdown-extensions: 1.1.1 - periscopic: 3.1.0 - remark-mdx: 2.3.0 - remark-parse: 10.0.2 - remark-rehype: 10.1.0 - unified: 10.1.2 - unist-util-position-from-estree: 1.1.2 - unist-util-stringify-position: 3.0.3 - unist-util-visit: 4.1.2 - vfile: 5.3.7 - transitivePeerDependencies: - - supports-color - - '@monaco-editor/loader@1.7.0': - dependencies: - state-local: 1.0.7 - - '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@monaco-editor/loader': 1.7.0 - monaco-editor: 0.52.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@monaco-editor/loader': 1.7.0 - monaco-editor: 0.52.2 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@npmcli/fs@3.1.1': - dependencies: - semver: 7.7.4 - - '@npmcli/git@4.1.0': - dependencies: - '@npmcli/promise-spawn': 6.0.2 - lru-cache: 7.18.3 - npm-pick-manifest: 8.0.2 - proc-log: 3.0.0 - promise-inflight: 1.0.1 - promise-retry: 2.0.1 - semver: 7.7.4 - which: 3.0.1 - transitivePeerDependencies: - - bluebird - - '@npmcli/package-json@4.0.1': - dependencies: - '@npmcli/git': 4.1.0 - glob: 10.5.0 - hosted-git-info: 6.1.3 - json-parse-even-better-errors: 3.0.2 - normalize-package-data: 5.0.0 - proc-log: 3.0.0 - semver: 7.7.4 - transitivePeerDependencies: - - bluebird - - '@npmcli/promise-spawn@6.0.2': - dependencies: - which: 3.0.1 - - '@pkgjs/parseargs@0.11.0': - optional: true - - '@playwright/test@1.58.2': - dependencies: - playwright: 1.58.2 - - '@radix-ui/number@1.1.1': {} - - '@radix-ui/primitive@1.1.3': {} - - '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/number': 1.1.1 - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - aria-hidden: 1.2.6 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/primitive': 1.1.3 - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/rect': 1.1.1 - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@19.2.4)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) - react: 19.2.4 - optionalDependencies: - '@types/react': 18.3.28 - - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - '@types/react-dom': 18.3.7(@types/react@18.3.28) - - '@radix-ui/rect@1.1.1': {} - - '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@remix-run/serve@2.17.4(typescript@5.9.3))(@types/node@25.3.2)(lightningcss@1.31.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1))': - dependencies: - '@babel/core': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/parser': 7.29.0 - '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) - '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) - '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 - '@mdx-js/mdx': 2.3.0 - '@npmcli/package-json': 4.0.1 - '@remix-run/node': 2.17.4(typescript@5.9.3) - '@remix-run/react': 2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) - '@remix-run/router': 1.23.2 - '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) - '@types/mdx': 2.0.13 - '@vanilla-extract/integration': 6.5.0(@types/node@25.3.2)(lightningcss@1.31.1) - arg: 5.0.2 - cacache: 17.1.4 - chalk: 4.1.2 - chokidar: 3.6.0 - cross-spawn: 7.0.6 - dotenv: 16.6.1 - es-module-lexer: 1.7.0 - esbuild: 0.17.6 - esbuild-plugins-node-modules-polyfill: 1.8.1(esbuild@0.17.6) - execa: 5.1.1 - exit-hook: 2.2.1 - express: 4.22.1 - fs-extra: 10.1.0 - get-port: 5.1.1 - gunzip-maybe: 1.4.2 - jsesc: 3.0.2 - json5: 2.2.3 - lodash: 4.17.23 - lodash.debounce: 4.0.8 - minimatch: 9.0.9 - ora: 5.4.1 - pathe: 1.1.2 - picocolors: 1.1.1 - picomatch: 2.3.1 - pidtree: 0.6.0 - postcss: 8.5.6 - postcss-discard-duplicates: 5.1.0(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6) - postcss-modules: 6.0.1(postcss@8.5.6) - prettier: 2.8.8 - pretty-ms: 7.0.1 - react-refresh: 0.14.2 - remark-frontmatter: 4.0.1 - remark-mdx-frontmatter: 1.1.1 - semver: 7.7.4 - set-cookie-parser: 2.7.2 - tar-fs: 2.1.4 - tsconfig-paths: 4.2.0 - valibot: 1.2.0(typescript@5.9.3) - vite-node: 3.2.4(@types/node@25.3.2)(lightningcss@1.31.1) - ws: 7.5.10 - optionalDependencies: - '@remix-run/serve': 2.17.4(typescript@5.9.3) - typescript: 5.9.3 - vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - bluebird - - bufferutil - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - ts-node - - utf-8-validate - - '@remix-run/express@2.17.4(express@4.22.1)(typescript@5.9.3)': - dependencies: - '@remix-run/node': 2.17.4(typescript@5.9.3) - express: 4.22.1 - optionalDependencies: - typescript: 5.9.3 - - '@remix-run/node@2.17.4(typescript@5.9.3)': - dependencies: - '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) - '@remix-run/web-fetch': 4.4.2 - '@web3-storage/multipart-parser': 1.0.0 - cookie-signature: 1.2.2 - source-map-support: 0.5.21 - stream-slice: 0.1.2 - undici: 6.23.0 - optionalDependencies: - typescript: 5.9.3 - - '@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': - dependencies: - '@remix-run/router': 1.23.2 - '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - react-router-dom: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - turbo-stream: 2.4.1 - optionalDependencies: - typescript: 5.9.3 - - '@remix-run/router@1.23.2': {} - - '@remix-run/serve@2.17.4(typescript@5.9.3)': - dependencies: - '@remix-run/express': 2.17.4(express@4.22.1)(typescript@5.9.3) - '@remix-run/node': 2.17.4(typescript@5.9.3) - chokidar: 3.6.0 - compression: 1.8.1 - express: 4.22.1 - get-port: 5.1.1 - morgan: 1.10.1 - source-map-support: 0.5.21 - transitivePeerDependencies: - - supports-color - - typescript - - '@remix-run/server-runtime@2.17.4(typescript@5.9.3)': - dependencies: - '@remix-run/router': 1.23.2 - '@types/cookie': 0.6.0 - '@web3-storage/multipart-parser': 1.0.0 - cookie: 0.7.2 - set-cookie-parser: 2.7.2 - source-map: 0.7.6 - turbo-stream: 2.4.1 - optionalDependencies: - typescript: 5.9.3 - - '@remix-run/web-blob@3.1.0': - dependencies: - '@remix-run/web-stream': 1.1.0 - web-encoding: 1.1.5 - - '@remix-run/web-fetch@4.4.2': - dependencies: - '@remix-run/web-blob': 3.1.0 - '@remix-run/web-file': 3.1.0 - '@remix-run/web-form-data': 3.1.0 - '@remix-run/web-stream': 1.1.0 - '@web3-storage/multipart-parser': 1.0.0 - abort-controller: 3.0.0 - data-uri-to-buffer: 3.0.1 - mrmime: 1.0.1 - - '@remix-run/web-file@3.1.0': - dependencies: - '@remix-run/web-blob': 3.1.0 - - '@remix-run/web-form-data@3.1.0': - dependencies: - web-encoding: 1.1.5 - - '@remix-run/web-stream@1.1.0': - dependencies: - web-streams-polyfill: 3.3.3 - - '@rollup/plugin-commonjs@25.0.8(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - commondir: 1.0.1 - estree-walker: 2.0.2 - glob: 8.1.0 - is-reference: 1.2.1 - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-node-resolve@15.3.1(rollup@4.59.0)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.11 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/plugin-typescript@11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.59.0) - resolve: 1.22.11 - typescript: 5.9.3 - optionalDependencies: - rollup: 4.59.0 - tslib: 2.8.1 - - '@rollup/pluginutils@5.3.0(rollup@4.59.0)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.59.0 - - '@rollup/rollup-android-arm-eabi@4.59.0': - optional: true - - '@rollup/rollup-android-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-arm64@4.59.0': - optional: true - - '@rollup/rollup-darwin-x64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-arm64@4.59.0': - optional: true - - '@rollup/rollup-freebsd-x64@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.59.0': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-linux-x64-musl@4.59.0': - optional: true - - '@rollup/rollup-openbsd-x64@4.59.0': - optional: true - - '@rollup/rollup-openharmony-arm64@4.59.0': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.59.0': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.59.0': - optional: true - - '@tailwindcss/node@4.2.1': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 - jiti: 2.6.1 - lightningcss: 1.31.1 - magic-string: 0.30.21 - source-map-js: 1.2.1 - tailwindcss: 4.2.1 - - '@tailwindcss/oxide-android-arm64@4.2.1': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.2.1': {} - - '@tailwindcss/oxide-darwin-x64@4.2.1': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.2.1': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.2.1': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.2.1': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.2.1': - optional: true - - '@tailwindcss/oxide@4.2.1': - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-arm64': 4.2.1 - '@tailwindcss/oxide-darwin-x64': 4.2.1 - '@tailwindcss/oxide-freebsd-x64': 4.2.1 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 - '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 - '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 - '@tailwindcss/oxide-linux-x64-musl': 4.2.1 - '@tailwindcss/oxide-wasm32-wasi': 4.2.1 - '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 - '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 - - '@tailwindcss/postcss@4.2.1': - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - postcss: 8.5.6 - tailwindcss: 4.2.1 - - '@tailwindcss/vite@4.2.1(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1))': - dependencies: - '@tailwindcss/node': 4.2.1 - '@tailwindcss/oxide': 4.2.1 - tailwindcss: 4.2.1 - vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - - '@trysound/sax@0.2.0': {} - - '@types/acorn@4.0.6': - dependencies: - '@types/estree': 1.0.8 - - '@types/cookie@0.6.0': {} - - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.8 - - '@types/estree@1.0.8': {} - - '@types/hast@2.3.10': - dependencies: - '@types/unist': 2.0.11 - - '@types/js-yaml@4.0.9': {} - - '@types/json-schema@7.0.15': {} - - '@types/mdast@3.0.15': - dependencies: - '@types/unist': 2.0.11 - - '@types/mdx@2.0.13': {} - - '@types/ms@2.1.0': {} - - '@types/node@25.3.2': - dependencies: - undici-types: 7.18.2 - - '@types/prop-types@15.7.15': {} - - '@types/react-dom@18.3.7(@types/react@18.3.28)': - dependencies: - '@types/react': 18.3.28 - - '@types/react@18.3.28': - dependencies: - '@types/prop-types': 15.7.15 - csstype: 3.2.3 - - '@types/resolve@1.20.2': {} - - '@types/semver@7.7.1': {} - - '@types/unist@2.0.11': {} - - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - graphemer: 1.4.0 - ignore: 5.3.2 - natural-compare: 1.4.0 - semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - eslint: 8.57.1 - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@6.21.0': - dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 - - '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3 - eslint: 8.57.1 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@6.21.0': {} - - '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': - dependencies: - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.4.3 - globby: 11.1.0 - is-glob: 4.0.3 - minimatch: 9.0.3 - semver: 7.7.4 - ts-api-utils: 1.4.3(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@types/json-schema': 7.0.15 - '@types/semver': 7.7.1 - '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) - eslint: 8.57.1 - semver: 7.7.4 - transitivePeerDependencies: - - supports-color - - typescript - - '@typescript-eslint/visitor-keys@6.21.0': - dependencies: - '@typescript-eslint/types': 6.21.0 - eslint-visitor-keys: 3.4.3 - - '@ungap/structured-clone@1.3.0': {} - - '@vanilla-extract/babel-plugin-debug-ids@1.2.2': - dependencies: - '@babel/core': 7.29.0 - transitivePeerDependencies: - - supports-color - - '@vanilla-extract/css@1.18.0': - dependencies: - '@emotion/hash': 0.9.2 - '@vanilla-extract/private': 1.0.9 - css-what: 6.2.2 - cssesc: 3.0.0 - csstype: 3.2.3 - dedent: 1.7.1 - deep-object-diff: 1.1.9 - deepmerge: 4.3.1 - lru-cache: 10.4.3 - media-query-parser: 2.0.2 - modern-ahocorasick: 1.1.0 - picocolors: 1.1.1 - transitivePeerDependencies: - - babel-plugin-macros - - '@vanilla-extract/integration@6.5.0(@types/node@25.3.2)(lightningcss@1.31.1)': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) - '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 - '@vanilla-extract/css': 1.18.0 - esbuild: 0.17.6 - eval: 0.1.8 - find-up: 5.0.0 - javascript-stringify: 2.1.0 - lodash: 4.17.23 - mlly: 1.8.0 - outdent: 0.8.0 - vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - vite-node: 1.6.1(@types/node@25.3.2)(lightningcss@1.31.1) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - '@vanilla-extract/private@1.0.9': {} - - '@web3-storage/multipart-parser@1.0.0': {} - - '@zxing/text-encoding@0.9.0': - optional: true - - abort-controller@3.0.0: - dependencies: - event-target-shim: 5.0.1 - - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 - - acorn-jsx@5.3.2(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - - acorn@8.16.0: {} - - aggregate-error@3.1.0: - dependencies: - clean-stack: 2.2.0 - indent-string: 4.0.0 - - ajv@6.14.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@5.0.2: {} - - argparse@2.0.1: {} - - aria-hidden@1.2.6: - dependencies: - tslib: 2.8.1 - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-flatten@1.1.1: {} - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array-union@2.1.0: {} - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 - - arraybuffer.prototype.slice@1.0.4: - dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - astring@1.9.0: {} - - async-function@1.0.0: {} - - autoprefixer@10.4.27(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 - - bail@2.0.2: {} - - balanced-match@1.0.2: {} - - base64-js@1.5.1: {} - - baseline-browser-mapping@2.10.0: {} - - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - binary-extensions@2.3.0: {} - - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 - - body-parser@1.20.4: - dependencies: - bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - on-finished: 2.4.1 - qs: 6.14.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - boolbase@1.0.0: {} - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - browserify-zlib@0.1.4: - dependencies: - pako: 0.2.9 - - browserslist@4.28.1: - dependencies: - baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001774 - electron-to-chromium: 1.5.302 - node-releases: 2.0.27 - update-browserslist-db: 1.2.3(browserslist@4.28.1) - - buffer-from@1.1.2: {} - - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - - bytes@3.1.2: {} - - cac@6.7.14: {} - - cacache@17.1.4: - dependencies: - '@npmcli/fs': 3.1.1 - fs-minipass: 3.0.3 - glob: 10.5.0 - lru-cache: 7.18.3 - minipass: 7.1.3 - minipass-collect: 1.0.2 - minipass-flush: 1.0.5 - minipass-pipeline: 1.2.4 - p-map: 4.0.0 - ssri: 10.0.6 - tar: 6.2.1 - unique-filename: 3.0.0 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - caniuse-api@3.0.0: - dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 - lodash.memoize: 4.1.2 - lodash.uniq: 4.5.0 - - caniuse-lite@1.0.30001774: {} - - ccount@2.0.1: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} - - character-reference-invalid@2.0.1: {} - - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - - chownr@1.1.4: {} - - chownr@2.0.0: {} - - class-variance-authority@0.7.1: - dependencies: - clsx: 2.1.1 - - clean-stack@2.2.0: {} - - cli-cursor@3.1.0: - dependencies: - restore-cursor: 3.1.0 - - cli-spinners@2.9.2: {} - - clone@1.0.4: {} - - clsx@2.1.1: {} - - cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) - '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - transitivePeerDependencies: - - '@types/react' - - '@types/react-dom' - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - colord@2.9.3: {} - - comma-separated-tokens@2.0.3: {} - - commander@7.2.0: {} - - commondir@1.0.1: {} - - compressible@2.0.18: - dependencies: - mime-db: 1.54.0 - - compression@1.8.1: - dependencies: - bytes: 3.1.2 - compressible: 2.0.18 - debug: 2.6.9 - negotiator: 0.6.4 - on-headers: 1.1.0 - safe-buffer: 5.2.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - concat-map@0.0.1: {} - - concat-with-sourcemaps@1.1.0: - dependencies: - source-map: 0.6.1 - - confbox@0.1.8: {} - - confbox@0.2.4: {} - - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 - - content-type@1.0.5: {} - - convert-source-map@2.0.0: {} - - cookie-signature@1.0.7: {} - - cookie-signature@1.2.2: {} - - cookie@0.7.2: {} - - core-util-is@1.0.3: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - css-declaration-sorter@6.4.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - css-select@4.3.0: - dependencies: - boolbase: 1.0.0 - css-what: 6.2.2 - domhandler: 4.3.1 - domutils: 2.8.0 - nth-check: 2.1.1 - - css-tree@1.1.3: - dependencies: - mdn-data: 2.0.14 - source-map: 0.6.1 - - css-what@6.2.2: {} - - cssesc@3.0.0: {} - - cssnano-preset-default@5.2.14(postcss@8.5.6): - dependencies: - css-declaration-sorter: 6.4.1(postcss@8.5.6) - cssnano-utils: 3.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-calc: 8.2.4(postcss@8.5.6) - postcss-colormin: 5.3.1(postcss@8.5.6) - postcss-convert-values: 5.1.3(postcss@8.5.6) - postcss-discard-comments: 5.1.2(postcss@8.5.6) - postcss-discard-duplicates: 5.1.0(postcss@8.5.6) - postcss-discard-empty: 5.1.1(postcss@8.5.6) - postcss-discard-overridden: 5.1.0(postcss@8.5.6) - postcss-merge-longhand: 5.1.7(postcss@8.5.6) - postcss-merge-rules: 5.1.4(postcss@8.5.6) - postcss-minify-font-values: 5.1.0(postcss@8.5.6) - postcss-minify-gradients: 5.1.1(postcss@8.5.6) - postcss-minify-params: 5.1.4(postcss@8.5.6) - postcss-minify-selectors: 5.2.1(postcss@8.5.6) - postcss-normalize-charset: 5.1.0(postcss@8.5.6) - postcss-normalize-display-values: 5.1.0(postcss@8.5.6) - postcss-normalize-positions: 5.1.1(postcss@8.5.6) - postcss-normalize-repeat-style: 5.1.1(postcss@8.5.6) - postcss-normalize-string: 5.1.0(postcss@8.5.6) - postcss-normalize-timing-functions: 5.1.0(postcss@8.5.6) - postcss-normalize-unicode: 5.1.1(postcss@8.5.6) - postcss-normalize-url: 5.1.0(postcss@8.5.6) - postcss-normalize-whitespace: 5.1.1(postcss@8.5.6) - postcss-ordered-values: 5.1.3(postcss@8.5.6) - postcss-reduce-initial: 5.1.2(postcss@8.5.6) - postcss-reduce-transforms: 5.1.0(postcss@8.5.6) - postcss-svgo: 5.1.0(postcss@8.5.6) - postcss-unique-selectors: 5.1.1(postcss@8.5.6) - - cssnano-utils@3.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - cssnano@5.1.15(postcss@8.5.6): - dependencies: - cssnano-preset-default: 5.2.14(postcss@8.5.6) - lilconfig: 2.1.0 - postcss: 8.5.6 - yaml: 1.10.2 - - csso@4.2.0: - dependencies: - css-tree: 1.1.3 - - csstype@3.2.3: {} - - data-uri-to-buffer@3.0.1: {} - - data-view-buffer@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - date-fns@3.6.0: {} - - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 - - dedent@1.7.1: {} - - deep-is@0.1.4: {} - - deep-object-diff@1.1.9: {} - - deepmerge@4.3.1: {} - - defaults@1.0.4: - dependencies: - clone: 1.0.4 - - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 - - depd@2.0.0: {} - - dequal@2.0.3: {} - - destroy@1.2.0: {} - - detect-libc@2.1.2: {} - - detect-node-es@1.1.0: {} - - diff@5.2.2: {} - - dir-glob@3.0.1: - dependencies: - path-type: 4.0.0 - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 - - doctrine@3.0.0: - dependencies: - esutils: 2.0.3 - - dom-serializer@1.4.1: - dependencies: - domelementtype: 2.3.0 - domhandler: 4.3.1 - entities: 2.2.0 - - domelementtype@2.3.0: {} - - domhandler@4.3.1: - dependencies: - domelementtype: 2.3.0 - - domutils@2.8.0: - dependencies: - dom-serializer: 1.4.1 - domelementtype: 2.3.0 - domhandler: 4.3.1 - - dotenv@16.6.1: {} - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - duplexify@3.7.1: - dependencies: - end-of-stream: 1.4.5 - inherits: 2.0.4 - readable-stream: 2.3.8 - stream-shift: 1.0.3 - - eastasianwidth@0.2.0: {} - - ee-first@1.1.1: {} - - electron-to-chromium@1.5.302: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - - encodeurl@2.0.0: {} - - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - - enhanced-resolve@5.19.0: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.0 - - entities@2.2.0: {} - - err-code@2.0.3: {} - - es-abstract@1.24.1: - dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-iterator-helpers@1.2.2: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 - - es-module-lexer@1.7.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - - es-set-tostringtag@2.1.0: - dependencies: - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - esbuild-plugins-node-modules-polyfill@1.8.1(esbuild@0.17.6): - dependencies: - '@jspm/core': 2.1.0 - esbuild: 0.17.6 - local-pkg: 1.1.2 - resolve.exports: 2.0.3 - - esbuild@0.17.6: - optionalDependencies: - '@esbuild/android-arm': 0.17.6 - '@esbuild/android-arm64': 0.17.6 - '@esbuild/android-x64': 0.17.6 - '@esbuild/darwin-arm64': 0.17.6 - '@esbuild/darwin-x64': 0.17.6 - '@esbuild/freebsd-arm64': 0.17.6 - '@esbuild/freebsd-x64': 0.17.6 - '@esbuild/linux-arm': 0.17.6 - '@esbuild/linux-arm64': 0.17.6 - '@esbuild/linux-ia32': 0.17.6 - '@esbuild/linux-loong64': 0.17.6 - '@esbuild/linux-mips64el': 0.17.6 - '@esbuild/linux-ppc64': 0.17.6 - '@esbuild/linux-riscv64': 0.17.6 - '@esbuild/linux-s390x': 0.17.6 - '@esbuild/linux-x64': 0.17.6 - '@esbuild/netbsd-x64': 0.17.6 - '@esbuild/openbsd-x64': 0.17.6 - '@esbuild/sunos-x64': 0.17.6 - '@esbuild/win32-arm64': 0.17.6 - '@esbuild/win32-ia32': 0.17.6 - '@esbuild/win32-x64': 0.17.6 - - 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 - - escalade@3.2.0: {} - - escape-html@1.0.3: {} - - escape-string-regexp@4.0.0: {} - - eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): - dependencies: - eslint: 8.57.1 - - eslint-plugin-react@7.37.5(eslint@8.57.1): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 8.57.1 - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.5 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.6 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-scope@7.2.2: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint@8.57.1: - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) - '@eslint-community/regexpp': 4.12.2 - '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.1 - '@humanwhocodes/config-array': 0.13.0 - '@humanwhocodes/module-importer': 1.0.1 - '@nodelib/fs.walk': 1.2.8 - '@ungap/structured-clone': 1.3.0 - ajv: 6.14.0 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - doctrine: 3.0.0 - escape-string-regexp: 4.0.0 - eslint-scope: 7.2.2 - eslint-visitor-keys: 3.4.3 - espree: 9.6.1 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 6.0.1 - find-up: 5.0.0 - glob-parent: 6.0.2 - globals: 13.24.0 - graphemer: 1.4.0 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - is-path-inside: 3.0.3 - js-yaml: 4.1.1 - json-stable-stringify-without-jsonify: 1.0.1 - levn: 0.4.1 - lodash.merge: 4.6.2 - minimatch: 3.1.5 - natural-compare: 1.4.0 - optionator: 0.9.4 - strip-ansi: 6.0.1 - text-table: 0.2.0 - transitivePeerDependencies: - - supports-color - - espree@9.6.1: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - eslint-visitor-keys: 3.4.3 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-util-attach-comments@2.1.1: - dependencies: - '@types/estree': 1.0.8 - - estree-util-build-jsx@2.2.2: - dependencies: - '@types/estree-jsx': 1.0.5 - estree-util-is-identifier-name: 2.1.0 - estree-walker: 3.0.3 - - estree-util-is-identifier-name@1.1.0: {} - - estree-util-is-identifier-name@2.1.0: {} - - estree-util-to-js@1.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - astring: 1.9.0 - source-map: 0.7.6 - - estree-util-value-to-estree@1.3.0: - dependencies: - is-plain-obj: 3.0.0 - - estree-util-visit@1.2.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/unist': 2.0.11 - - estree-walker@0.6.1: {} - - estree-walker@2.0.2: {} - - estree-walker@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - esutils@2.0.3: {} - - etag@1.8.1: {} - - eval@0.1.8: - dependencies: - '@types/node': 25.3.2 - require-like: 0.1.2 - - event-target-shim@5.0.1: {} - - eventemitter3@4.0.7: {} - - execa@5.1.1: - dependencies: - cross-spawn: 7.0.6 - get-stream: 6.0.1 - human-signals: 2.1.0 - is-stream: 2.0.1 - merge-stream: 2.0.0 - npm-run-path: 4.0.1 - onetime: 5.1.2 - signal-exit: 3.0.7 - strip-final-newline: 2.0.0 - - exit-hook@2.2.1: {} - - express@4.22.1: - dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.4 - content-disposition: 0.5.4 - content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 - depd: 2.0.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 - http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 - on-finished: 2.4.1 - parseurl: 1.3.3 - path-to-regexp: 0.1.12 - proxy-addr: 2.0.7 - qs: 6.14.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 - statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 - vary: 1.1.2 - transitivePeerDependencies: - - supports-color - - exsolve@1.0.8: {} - - extend@3.0.2: {} - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastq@1.20.1: - dependencies: - reusify: 1.1.0 - - fault@2.0.1: - dependencies: - format: 0.2.2 - - file-entry-cache@6.0.1: - dependencies: - flat-cache: 3.2.0 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 - encodeurl: 2.0.0 - escape-html: 1.0.3 - on-finished: 2.4.1 - parseurl: 1.3.3 - statuses: 2.0.2 - unpipe: 1.0.0 - transitivePeerDependencies: - - supports-color - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@3.2.0: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - rimraf: 3.0.2 - - flatted@3.3.3: {} - - for-each@0.3.5: - dependencies: - is-callable: 1.2.7 - - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - - format@0.2.2: {} - - forwarded@0.2.0: {} - - fraction.js@5.3.4: {} - - fresh@0.5.2: {} - - fs-constants@1.0.0: {} - - fs-extra@10.1.0: - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.2.0 - universalify: 2.0.1 - - fs-minipass@2.1.0: - dependencies: - minipass: 3.3.6 - - fs-minipass@3.0.3: - dependencies: - minipass: 7.1.3 - - fs.realpath@1.0.0: {} - - fsevents@2.3.2: - optional: true - - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - - generic-names@4.0.0: - dependencies: - loader-utils: 3.3.1 - - gensync@1.0.0-beta.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-nonce@1.0.1: {} - - get-port@5.1.1: {} - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - get-stream@6.0.1: {} - - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.9 - minipass: 7.1.3 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - glob@7.2.3: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.5 - once: 1.4.0 - path-is-absolute: 1.0.1 - - glob@8.1.0: - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 5.1.9 - once: 1.4.0 - - globals@13.24.0: - dependencies: - type-fest: 0.20.2 - - globalthis@1.0.4: - dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 - - globby@11.1.0: - dependencies: - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.3.3 - ignore: 5.3.2 - merge2: 1.4.1 - slash: 3.0.0 - - gopd@1.2.0: {} - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - gunzip-maybe@1.4.2: - dependencies: - browserify-zlib: 0.1.4 - is-deflate: 1.0.0 - is-gzip: 1.0.0 - peek-stream: 1.1.3 - pumpify: 1.5.1 - through2: 2.0.5 - - has-bigints@1.1.0: {} - - has-flag@4.0.0: {} - - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - - has-symbols@1.1.0: {} - - has-tostringtag@1.0.2: - dependencies: - has-symbols: 1.1.0 - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - hast-util-to-estree@2.3.3: - dependencies: - '@types/estree': 1.0.8 - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/unist': 2.0.11 - comma-separated-tokens: 2.0.3 - estree-util-attach-comments: 2.1.1 - estree-util-is-identifier-name: 2.1.0 - hast-util-whitespace: 2.0.1 - mdast-util-mdx-expression: 1.3.2 - mdast-util-mdxjs-esm: 1.3.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - style-to-object: 0.4.4 - unist-util-position: 4.0.4 - zwitch: 2.0.4 - transitivePeerDependencies: - - supports-color - - hast-util-whitespace@2.0.1: {} - - hosted-git-info@6.1.3: - dependencies: - lru-cache: 7.18.3 - - http-errors@2.0.1: - dependencies: - depd: 2.0.0 - inherits: 2.0.4 - setprototypeof: 1.2.0 - statuses: 2.0.2 - toidentifier: 1.0.1 - - human-signals@2.1.0: {} - - iconv-lite@0.4.24: - dependencies: - safer-buffer: 2.1.2 - - icss-replace-symbols@1.1.0: {} - - icss-utils@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - ieee754@1.2.1: {} - - ignore@5.3.2: {} - - import-cwd@3.0.0: - dependencies: - import-from: 3.0.0 - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - import-from@3.0.0: - dependencies: - resolve-from: 5.0.0 - - imurmurhash@0.1.4: {} - - indent-string@4.0.0: {} - - inflight@1.0.6: - dependencies: - once: 1.4.0 - wrappy: 1.0.2 - - inherits@2.0.4: {} - - inline-style-parser@0.1.1: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - - ipaddr.js@1.9.1: {} - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-arguments@1.2.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-buffer@2.0.5: {} - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-decimal@2.0.1: {} - - is-deflate@1.0.0: {} - - is-extglob@2.1.1: {} - - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-fullwidth-code-point@3.0.0: {} - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-gzip@1.0.0: {} - - is-hexadecimal@2.0.1: {} - - is-interactive@1.0.0: {} - - is-map@2.0.3: {} - - is-module@1.0.0: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-number@7.0.0: {} - - is-path-inside@3.0.3: {} - - is-plain-obj@3.0.0: {} - - is-plain-obj@4.1.0: {} - - is-reference@1.2.1: - dependencies: - '@types/estree': 1.0.8 - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - is-regex@1.2.1: - dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 - - is-set@2.0.3: {} - - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-stream@2.0.1: {} - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 - - is-typed-array@1.1.15: - dependencies: - which-typed-array: 1.1.20 - - is-unicode-supported@0.1.0: {} - - is-weakmap@2.0.2: {} - - is-weakref@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-weakset@2.0.4: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@1.0.0: {} - - isarray@2.0.5: {} - - isbot@5.1.35: {} - - isexe@2.0.0: {} - - iterator.prototype@1.1.5: - dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - - javascript-stringify@2.1.0: {} - - jiti@2.6.1: {} - - js-tokens@4.0.0: {} - - js-yaml@4.1.1: - dependencies: - argparse: 2.0.1 - - jsesc@3.0.2: {} - - json-buffer@3.0.1: {} - - json-parse-even-better-errors@3.0.2: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - json5@2.2.3: {} - - jsonfile@6.2.0: - dependencies: - universalify: 2.0.1 - optionalDependencies: - graceful-fs: 4.2.11 - - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@4.1.5: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-android-arm64@1.31.1: - optional: true - - lightningcss-darwin-arm64@1.31.1: - optional: true - - lightningcss-darwin-x64@1.31.1: - optional: true - - lightningcss-freebsd-x64@1.31.1: - optional: true - - lightningcss-linux-arm-gnueabihf@1.31.1: - optional: true - - lightningcss-linux-arm64-gnu@1.31.1: - optional: true - - lightningcss-linux-arm64-musl@1.31.1: - optional: true - - lightningcss-linux-x64-gnu@1.31.1: - optional: true - - lightningcss-linux-x64-musl@1.31.1: - optional: true - - lightningcss-win32-arm64-msvc@1.31.1: - optional: true - - lightningcss-win32-x64-msvc@1.31.1: - optional: true - - lightningcss@1.31.1: - dependencies: - detect-libc: 2.1.2 - optionalDependencies: - lightningcss-android-arm64: 1.31.1 - lightningcss-darwin-arm64: 1.31.1 - lightningcss-darwin-x64: 1.31.1 - lightningcss-freebsd-x64: 1.31.1 - lightningcss-linux-arm-gnueabihf: 1.31.1 - lightningcss-linux-arm64-gnu: 1.31.1 - lightningcss-linux-arm64-musl: 1.31.1 - lightningcss-linux-x64-gnu: 1.31.1 - lightningcss-linux-x64-musl: 1.31.1 - lightningcss-win32-arm64-msvc: 1.31.1 - lightningcss-win32-x64-msvc: 1.31.1 - - lilconfig@2.1.0: {} - - lilconfig@3.1.3: {} - - loader-utils@3.3.1: {} - - local-pkg@1.1.2: - dependencies: - mlly: 1.8.0 - pkg-types: 2.3.0 - quansync: 0.2.11 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.camelcase@4.3.0: {} - - lodash.debounce@4.0.8: {} - - lodash.memoize@4.1.2: {} - - lodash.merge@4.6.2: {} - - lodash.uniq@4.5.0: {} - - lodash@4.17.23: {} - - log-symbols@4.1.0: - dependencies: - chalk: 4.1.2 - is-unicode-supported: 0.1.0 - - longest-streak@3.1.0: {} - - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - - lru-cache@10.4.3: {} - - lru-cache@5.1.1: - dependencies: - yallist: 3.1.1 - - lru-cache@7.18.3: {} - - lucide-react@0.555.0(react@18.3.1): - dependencies: - react: 18.3.1 - - lucide-react@0.563.0(react@19.2.4): - dependencies: - react: 19.2.4 - - magic-string@0.30.21: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - markdown-extensions@1.1.1: {} - - math-intrinsics@1.1.0: {} - - mdast-util-definitions@5.1.2: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - unist-util-visit: 4.1.2 - - mdast-util-from-markdown@1.3.1: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - decode-named-character-reference: 1.3.0 - mdast-util-to-string: 3.2.0 - micromark: 3.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-decode-string: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-stringify-position: 3.0.3 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color - - mdast-util-frontmatter@1.0.1: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-to-markdown: 1.5.0 - micromark-extension-frontmatter: 1.1.1 - - mdast-util-mdx-expression@1.3.2: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@2.1.4: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - ccount: 2.0.1 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-remove-position: 4.0.2 - unist-util-stringify-position: 3.0.3 - vfile-message: 3.1.4 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx@2.0.1: - dependencies: - mdast-util-from-markdown: 1.3.1 - mdast-util-mdx-expression: 1.3.2 - mdast-util-mdx-jsx: 2.1.4 - mdast-util-mdxjs-esm: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@1.3.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - mdast-util-to-markdown: 1.5.0 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@3.0.1: - dependencies: - '@types/mdast': 3.0.15 - unist-util-is: 5.2.1 - - mdast-util-to-hast@12.3.0: - dependencies: - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-definitions: 5.1.2 - micromark-util-sanitize-uri: 1.2.0 - trim-lines: 3.0.1 - unist-util-generated: 2.0.1 - unist-util-position: 4.0.4 - unist-util-visit: 4.1.2 - - mdast-util-to-markdown@1.5.0: - dependencies: - '@types/mdast': 3.0.15 - '@types/unist': 2.0.11 - longest-streak: 3.1.0 - mdast-util-phrasing: 3.0.1 - mdast-util-to-string: 3.2.0 - micromark-util-decode-string: 1.1.0 - unist-util-visit: 4.1.2 - zwitch: 2.0.4 - - mdast-util-to-string@3.2.0: - dependencies: - '@types/mdast': 3.0.15 - - mdn-data@2.0.14: {} - - media-query-parser@2.0.2: - dependencies: - '@babel/runtime': 7.28.6 - - media-typer@0.3.0: {} - - merge-descriptors@1.0.3: {} - - merge-stream@2.0.0: {} - - merge2@1.4.1: {} - - methods@1.1.2: {} - - micromark-core-commonmark@1.1.0: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-factory-destination: 1.1.0 - micromark-factory-label: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-factory-title: 1.1.0 - micromark-factory-whitespace: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-classify-character: 1.1.0 - micromark-util-html-tag-name: 1.2.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-frontmatter@1.1.1: - dependencies: - fault: 2.0.1 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-extension-mdx-expression@1.0.8: - dependencies: - '@types/estree': 1.0.8 - micromark-factory-mdx-expression: 1.0.9 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-extension-mdx-jsx@1.0.5: - dependencies: - '@types/acorn': 4.0.6 - '@types/estree': 1.0.8 - estree-util-is-identifier-name: 2.1.0 - micromark-factory-mdx-expression: 1.0.9 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - vfile-message: 3.1.4 - - micromark-extension-mdx-md@1.0.1: - dependencies: - micromark-util-types: 1.1.0 - - micromark-extension-mdxjs-esm@1.0.5: - dependencies: - '@types/estree': 1.0.8 - micromark-core-commonmark: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-position-from-estree: 1.1.2 - uvu: 0.5.6 - vfile-message: 3.1.4 - - micromark-extension-mdxjs@1.0.1: - dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) - micromark-extension-mdx-expression: 1.0.8 - micromark-extension-mdx-jsx: 1.0.5 - micromark-extension-mdx-md: 1.0.1 - micromark-extension-mdxjs-esm: 1.0.5 - micromark-util-combine-extensions: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-factory-destination@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-factory-label@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-factory-mdx-expression@1.0.9: - dependencies: - '@types/estree': 1.0.8 - micromark-util-character: 1.2.0 - micromark-util-events-to-acorn: 1.2.3 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - unist-util-position-from-estree: 1.1.2 - uvu: 0.5.6 - vfile-message: 3.1.4 - - micromark-factory-space@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-types: 1.1.0 - - micromark-factory-title@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-factory-whitespace@1.1.0: - dependencies: - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-util-character@1.2.0: - dependencies: - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-util-chunked@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - - micromark-util-classify-character@1.1.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-util-combine-extensions@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-types: 1.1.0 - - micromark-util-decode-numeric-character-reference@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - - micromark-util-decode-string@1.1.0: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 1.2.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-symbol: 1.1.0 - - micromark-util-encode@1.1.0: {} - - micromark-util-events-to-acorn@1.2.3: - dependencies: - '@types/acorn': 4.0.6 - '@types/estree': 1.0.8 - '@types/unist': 2.0.11 - estree-util-visit: 1.2.1 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - vfile-message: 3.1.4 - - micromark-util-html-tag-name@1.2.0: {} - - micromark-util-normalize-identifier@1.1.0: - dependencies: - micromark-util-symbol: 1.1.0 - - micromark-util-resolve-all@1.1.0: - dependencies: - micromark-util-types: 1.1.0 - - micromark-util-sanitize-uri@1.2.0: - dependencies: - micromark-util-character: 1.2.0 - micromark-util-encode: 1.1.0 - micromark-util-symbol: 1.1.0 - - micromark-util-subtokenize@1.1.0: - dependencies: - micromark-util-chunked: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - - micromark-util-symbol@1.1.0: {} - - micromark-util-types@1.1.0: {} - - micromark@3.2.0: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - micromark-core-commonmark: 1.1.0 - micromark-factory-space: 1.1.0 - micromark-util-character: 1.2.0 - micromark-util-chunked: 1.1.0 - micromark-util-combine-extensions: 1.1.0 - micromark-util-decode-numeric-character-reference: 1.1.0 - micromark-util-encode: 1.1.0 - micromark-util-normalize-identifier: 1.1.0 - micromark-util-resolve-all: 1.1.0 - micromark-util-sanitize-uri: 1.2.0 - micromark-util-subtokenize: 1.1.0 - micromark-util-symbol: 1.1.0 - micromark-util-types: 1.1.0 - uvu: 0.5.6 - transitivePeerDependencies: - - supports-color - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - mime-db@1.52.0: {} - - mime-db@1.54.0: {} - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - - mime@1.6.0: {} - - mimic-fn@2.1.0: {} - - minimatch@3.1.5: - dependencies: - brace-expansion: 1.1.12 - - minimatch@5.1.9: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.3: - dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.9: - dependencies: - brace-expansion: 2.0.2 - - minimist@1.2.8: {} - - minipass-collect@1.0.2: - dependencies: - minipass: 3.3.6 - - minipass-flush@1.0.5: - dependencies: - minipass: 3.3.6 - - minipass-pipeline@1.2.4: - dependencies: - minipass: 3.3.6 - - minipass@3.3.6: - dependencies: - yallist: 4.0.0 - - minipass@5.0.0: {} - - minipass@7.1.3: {} - - minizlib@2.1.2: - dependencies: - minipass: 3.3.6 - yallist: 4.0.0 - - mkdirp-classic@0.5.3: {} - - mkdirp@1.0.4: {} - - mlly@1.8.0: - dependencies: - acorn: 8.16.0 - pathe: 2.0.3 - pkg-types: 1.3.1 - ufo: 1.6.3 - - modern-ahocorasick@1.1.0: {} - - monaco-editor@0.52.2: {} - - morgan@1.10.1: - dependencies: - basic-auth: 2.0.1 - debug: 2.6.9 - depd: 2.0.0 - on-finished: 2.3.0 - on-headers: 1.1.0 - transitivePeerDependencies: - - supports-color - - mri@1.2.0: {} - - mrmime@1.0.1: {} - - ms@2.0.0: {} - - ms@2.1.3: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - negotiator@0.6.3: {} - - negotiator@0.6.4: {} - - node-exports-info@1.6.0: - dependencies: - array.prototype.flatmap: 1.3.3 - es-errors: 1.3.0 - object.entries: 1.1.9 - semver: 6.3.1 - - node-releases@2.0.27: {} - - normalize-package-data@5.0.0: - dependencies: - hosted-git-info: 6.1.3 - is-core-module: 2.16.1 - semver: 7.7.4 - validate-npm-package-license: 3.0.4 - - normalize-path@3.0.0: {} - - normalize-url@6.1.0: {} - - npm-install-checks@6.3.0: - dependencies: - semver: 7.7.4 - - npm-normalize-package-bin@3.0.1: {} - - npm-package-arg@10.1.0: - dependencies: - hosted-git-info: 6.1.3 - proc-log: 3.0.0 - semver: 7.7.4 - validate-npm-package-name: 5.0.1 - - npm-pick-manifest@8.0.2: - dependencies: - npm-install-checks: 6.3.0 - npm-normalize-package-bin: 3.0.1 - npm-package-arg: 10.1.0 - semver: 7.7.4 - - npm-run-path@4.0.1: - dependencies: - path-key: 3.1.1 - - nth-check@2.1.1: - dependencies: - boolbase: 1.0.0 - - object-assign@4.1.1: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} - - object.assign@4.1.7: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - object.fromentries@2.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - on-finished@2.3.0: - dependencies: - ee-first: 1.1.1 - - on-finished@2.4.1: - dependencies: - ee-first: 1.1.1 - - on-headers@1.1.0: {} - - once@1.4.0: - dependencies: - wrappy: 1.0.2 - - onetime@5.1.2: - dependencies: - mimic-fn: 2.1.0 - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - ora@5.4.1: - dependencies: - bl: 4.1.0 - chalk: 4.1.2 - cli-cursor: 3.1.0 - cli-spinners: 2.9.2 - is-interactive: 1.0.0 - is-unicode-supported: 0.1.0 - log-symbols: 4.1.0 - strip-ansi: 6.0.1 - wcwidth: 1.0.1 - - outdent@0.8.0: {} - - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 - - p-finally@1.0.0: {} - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - p-map@4.0.0: - dependencies: - aggregate-error: 3.1.0 - - p-queue@6.6.2: - dependencies: - eventemitter3: 4.0.7 - p-timeout: 3.2.0 - - p-timeout@3.2.0: - dependencies: - p-finally: 1.0.0 - - package-json-from-dist@1.0.1: {} - - pako@0.2.9: {} - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.3.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 - - parse-ms@2.1.0: {} - - parseurl@1.3.3: {} - - path-exists@4.0.0: {} - - path-is-absolute@1.0.1: {} - - path-key@3.1.1: {} - - path-parse@1.0.7: {} - - path-scurry@1.11.1: - dependencies: - lru-cache: 10.4.3 - minipass: 7.1.3 - - path-to-regexp@0.1.12: {} - - path-type@4.0.0: {} - - pathe@1.1.2: {} - - pathe@2.0.3: {} - - peek-stream@1.1.3: - dependencies: - buffer-from: 1.1.2 - duplexify: 3.7.1 - through2: 2.0.5 - - periscopic@3.1.0: - dependencies: - '@types/estree': 1.0.8 - estree-walker: 3.0.3 - is-reference: 3.0.3 - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.3: {} - - pidtree@0.6.0: {} - - pify@5.0.0: {} - - pkg-types@1.3.1: - dependencies: - confbox: 0.1.8 - mlly: 1.8.0 - pathe: 2.0.3 - - pkg-types@2.3.0: - dependencies: - confbox: 0.2.4 - exsolve: 1.0.8 - pathe: 2.0.3 - - playwright-core@1.58.2: {} - - playwright@1.58.2: - dependencies: - playwright-core: 1.58.2 - optionalDependencies: - fsevents: 2.3.2 - - possible-typed-array-names@1.1.0: {} - - postcss-calc@8.2.4(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - postcss-value-parser: 4.2.0 - - postcss-colormin@5.3.1(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - colord: 2.9.3 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-convert-values@5.1.3(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-discard-comments@5.1.2(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-discard-duplicates@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-discard-empty@5.1.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-discard-overridden@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-load-config@3.1.4(postcss@8.5.6): - dependencies: - lilconfig: 2.1.0 - yaml: 1.10.2 - optionalDependencies: - postcss: 8.5.6 - - postcss-load-config@4.0.2(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - yaml: 2.8.2 - optionalDependencies: - postcss: 8.5.6 - - postcss-merge-longhand@5.1.7(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - stylehacks: 5.1.1(postcss@8.5.6) - - postcss-merge-rules@5.1.4(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - cssnano-utils: 3.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-minify-font-values@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-minify-gradients@5.1.1(postcss@8.5.6): - dependencies: - colord: 2.9.3 - cssnano-utils: 3.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-minify-params@5.1.4(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - cssnano-utils: 3.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-minify-selectors@5.2.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-modules-extract-imports@3.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-modules-local-by-default@4.2.0(postcss@8.5.6): - dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-selector-parser: 7.1.1 - postcss-value-parser: 4.2.0 - - postcss-modules-scope@3.2.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 7.1.1 - - postcss-modules-values@4.0.0(postcss@8.5.6): - dependencies: - icss-utils: 5.1.0(postcss@8.5.6) - postcss: 8.5.6 - - postcss-modules@4.3.1(postcss@8.5.6): - dependencies: - generic-names: 4.0.0 - icss-replace-symbols: 1.1.0 - lodash.camelcase: 4.3.0 - postcss: 8.5.6 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) - postcss-modules-scope: 3.2.1(postcss@8.5.6) - postcss-modules-values: 4.0.0(postcss@8.5.6) - string-hash: 1.1.3 - - postcss-modules@6.0.1(postcss@8.5.6): - dependencies: - generic-names: 4.0.0 - icss-utils: 5.1.0(postcss@8.5.6) - lodash.camelcase: 4.3.0 - postcss: 8.5.6 - postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) - postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) - postcss-modules-scope: 3.2.1(postcss@8.5.6) - postcss-modules-values: 4.0.0(postcss@8.5.6) - string-hash: 1.1.3 - - postcss-normalize-charset@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-normalize-display-values@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-positions@5.1.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-repeat-style@5.1.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-string@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-timing-functions@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-unicode@5.1.1(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-url@5.1.0(postcss@8.5.6): - dependencies: - normalize-url: 6.1.0 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-normalize-whitespace@5.1.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-ordered-values@5.1.3(postcss@8.5.6): - dependencies: - cssnano-utils: 3.1.0(postcss@8.5.6) - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-reduce-initial@5.1.2(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - caniuse-api: 3.0.0 - postcss: 8.5.6 - - postcss-reduce-transforms@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-selector-parser@7.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-svgo@5.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - svgo: 2.8.0 - - postcss-unique-selectors@5.1.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier@2.8.8: {} - - pretty-ms@7.0.1: - dependencies: - parse-ms: 2.1.0 - - proc-log@3.0.0: {} - - process-nextick-args@2.0.1: {} - - promise-inflight@1.0.1: {} - - promise-retry@2.0.1: - dependencies: - err-code: 2.0.3 - retry: 0.12.0 - - promise.series@0.2.0: {} - - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - property-information@6.5.0: {} - - proxy-addr@2.0.7: - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - - pump@2.0.1: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - - pumpify@1.5.1: - dependencies: - duplexify: 3.7.1 - inherits: 2.0.4 - pump: 2.0.1 - - punycode@2.3.1: {} - - qs@6.14.2: - dependencies: - side-channel: 1.1.0 - - quansync@0.2.11: {} - - queue-microtask@1.2.3: {} - - range-parser@1.2.1: {} - - raw-body@2.5.3: - dependencies: - bytes: 3.1.2 - http-errors: 2.0.1 - iconv-lite: 0.4.24 - unpipe: 1.0.0 - - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - - react-dom@19.2.4(react@19.2.4): - dependencies: - react: 19.2.4 - scheduler: 0.27.0 - - react-is@16.13.1: {} - - react-refresh@0.14.2: {} - - react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@19.2.4): - dependencies: - react: 19.2.4 - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.4) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react-remove-scroll@2.7.2(@types/react@18.3.28)(react@19.2.4): - dependencies: - react: 19.2.4 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@19.2.4) - react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.4) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.28)(react@19.2.4) - use-sidecar: 1.1.3(@types/react@18.3.28)(react@19.2.4) - optionalDependencies: - '@types/react': 18.3.28 - - react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.3(react@18.3.1) - - react-router@6.30.3(react@18.3.1): - dependencies: - '@remix-run/router': 1.23.2 - react: 18.3.1 - - react-style-singleton@2.2.3(@types/react@18.3.28)(react@19.2.4): - dependencies: - get-nonce: 1.0.1 - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - - react@19.2.4: {} - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - - readable-stream@3.6.2: - dependencies: - inherits: 2.0.4 - string_decoder: 1.3.0 - util-deprecate: 1.0.2 - - readdirp@3.6.0: - dependencies: - picomatch: 2.3.1 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - remark-frontmatter@4.0.1: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-frontmatter: 1.0.1 - micromark-extension-frontmatter: 1.1.1 - unified: 10.1.2 - - remark-mdx-frontmatter@1.1.1: - dependencies: - estree-util-is-identifier-name: 1.1.0 - estree-util-value-to-estree: 1.3.0 - js-yaml: 4.1.1 - toml: 3.0.0 - - remark-mdx@2.3.0: - dependencies: - mdast-util-mdx: 2.0.1 - micromark-extension-mdxjs: 1.0.1 - transitivePeerDependencies: - - supports-color - - remark-parse@10.0.2: - dependencies: - '@types/mdast': 3.0.15 - mdast-util-from-markdown: 1.3.1 - unified: 10.1.2 - transitivePeerDependencies: - - supports-color - - remark-rehype@10.1.0: - dependencies: - '@types/hast': 2.3.10 - '@types/mdast': 3.0.15 - mdast-util-to-hast: 12.3.0 - unified: 10.1.2 - - require-like@0.1.2: {} - - resolve-from@4.0.0: {} - - resolve-from@5.0.0: {} - - resolve.exports@2.0.3: {} - - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.6: - dependencies: - es-errors: 1.3.0 - is-core-module: 2.16.1 - node-exports-info: 1.6.0 - object-keys: 1.1.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - restore-cursor@3.1.0: - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - - retry@0.12.0: {} - - reusify@1.1.0: {} - - rimraf@3.0.2: - dependencies: - glob: 7.2.3 - - rollup-plugin-peer-deps-external@2.2.4(rollup@4.59.0): - dependencies: - rollup: 4.59.0 - - rollup-plugin-postcss@4.0.2(postcss@8.5.6): - dependencies: - chalk: 4.1.2 - concat-with-sourcemaps: 1.1.0 - cssnano: 5.1.15(postcss@8.5.6) - import-cwd: 3.0.0 - p-queue: 6.6.2 - pify: 5.0.0 - postcss: 8.5.6 - postcss-load-config: 3.1.4(postcss@8.5.6) - postcss-modules: 4.3.1(postcss@8.5.6) - promise.series: 0.2.0 - resolve: 1.22.11 - rollup-pluginutils: 2.8.2 - safe-identifier: 0.4.2 - style-inject: 0.3.0 - transitivePeerDependencies: - - ts-node - - rollup-pluginutils@2.8.2: - dependencies: - estree-walker: 0.6.1 - - rollup@4.59.0: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 - fsevents: 2.3.3 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - sade@1.8.1: - dependencies: - mri: 1.2.0 - - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-buffer@5.1.2: {} - - safe-buffer@5.2.1: {} - - safe-identifier@0.4.2: {} - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 - - safe-regex-test@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 - - safer-buffer@2.1.2: {} - - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - - scheduler@0.27.0: {} - - semver@6.3.1: {} - - semver@7.7.4: {} - - send@0.19.2: - dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 - encodeurl: 2.0.0 - escape-html: 1.0.3 - etag: 1.8.1 - fresh: 0.5.2 - http-errors: 2.0.1 - mime: 1.6.0 - ms: 2.1.3 - on-finished: 2.4.1 - range-parser: 1.2.1 - statuses: 2.0.2 - transitivePeerDependencies: - - supports-color - - serve-static@1.16.3: - dependencies: - encodeurl: 2.0.0 - escape-html: 1.0.3 - parseurl: 1.3.3 - send: 0.19.2 - transitivePeerDependencies: - - supports-color - - set-cookie-parser@2.7.2: {} - - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - setprototypeof@1.2.0: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - signal-exit@3.0.7: {} - - signal-exit@4.1.0: {} - - slash@3.0.0: {} - - source-map-js@1.2.1: {} - - source-map-support@0.5.21: - dependencies: - buffer-from: 1.1.2 - source-map: 0.6.1 - - source-map@0.6.1: {} - - source-map@0.7.6: {} - - space-separated-tokens@2.0.2: {} - - spdx-correct@3.2.0: - dependencies: - spdx-expression-parse: 3.0.1 - spdx-license-ids: 3.0.23 - - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@3.0.1: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.23 - - spdx-license-ids@3.0.23: {} - - ssri@10.0.6: - dependencies: - minipass: 7.1.3 - - stable@0.1.8: {} - - state-local@1.0.7: {} - - statuses@2.0.2: {} - - stop-iteration-iterator@1.1.0: - dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 - - stream-shift@1.0.3: {} - - stream-slice@0.1.2: {} - - string-hash@1.1.3: {} - - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.2.0 - - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 - - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.1 - - string.prototype.trim@1.2.10: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 - - string.prototype.trimend@1.0.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string.prototype.trimstart@1.0.8: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 - - string_decoder@1.3.0: - dependencies: - safe-buffer: 5.2.1 - - stringify-entities@4.0.4: - dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.2.0: - dependencies: - ansi-regex: 6.2.2 - - strip-bom@3.0.0: {} - - strip-final-newline@2.0.0: {} - - strip-json-comments@3.1.1: {} - - style-inject@0.3.0: {} - - style-to-object@0.4.4: - dependencies: - inline-style-parser: 0.1.1 - - stylehacks@5.1.1(postcss@8.5.6): - dependencies: - browserslist: 4.28.1 - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-preserve-symlinks-flag@1.0.0: {} - - svgo@2.8.0: - dependencies: - '@trysound/sax': 0.2.0 - commander: 7.2.0 - css-select: 4.3.0 - css-tree: 1.1.3 - csso: 4.2.0 - picocolors: 1.1.1 - stable: 0.1.8 - - tailwind-merge@3.5.0: {} - - tailwindcss-animate@1.0.7(tailwindcss@4.2.1): - dependencies: - tailwindcss: 4.2.1 - - tailwindcss@4.2.1: {} - - tapable@2.3.0: {} - - tar-fs@2.1.4: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - - tar@6.2.1: - dependencies: - chownr: 2.0.0 - fs-minipass: 2.1.0 - minipass: 5.0.0 - minizlib: 2.1.2 - mkdirp: 1.0.4 - yallist: 4.0.0 - - text-table@0.2.0: {} - - through2@2.0.5: - dependencies: - readable-stream: 2.3.8 - xtend: 4.0.2 - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - toidentifier@1.0.1: {} - - toml@3.0.0: {} - - trim-lines@3.0.1: {} - - trough@2.2.0: {} - - ts-api-utils@1.4.3(typescript@5.9.3): - dependencies: - typescript: 5.9.3 - - tsconfig-paths@4.2.0: - dependencies: - json5: 2.2.3 - minimist: 1.2.8 - strip-bom: 3.0.0 - - tslib@2.8.1: {} - - turbo-stream@2.4.1: {} - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@0.20.2: {} - - type-is@1.6.18: - dependencies: - media-typer: 0.3.0 - mime-types: 2.1.35 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript@5.9.3: {} - - ufo@1.6.3: {} - - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - - undici-types@7.18.2: {} - - undici@6.23.0: {} - - unified@10.1.2: - dependencies: - '@types/unist': 2.0.11 - bail: 2.0.2 - extend: 3.0.2 - is-buffer: 2.0.5 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 5.3.7 - - unique-filename@3.0.0: - dependencies: - unique-slug: 4.0.0 - - unique-slug@4.0.0: - dependencies: - imurmurhash: 0.1.4 - - unist-util-generated@2.0.1: {} - - unist-util-is@5.2.1: - dependencies: - '@types/unist': 2.0.11 - - unist-util-position-from-estree@1.1.2: - dependencies: - '@types/unist': 2.0.11 - - unist-util-position@4.0.4: - dependencies: - '@types/unist': 2.0.11 - - unist-util-remove-position@4.0.2: - dependencies: - '@types/unist': 2.0.11 - unist-util-visit: 4.1.2 - - unist-util-stringify-position@3.0.3: - dependencies: - '@types/unist': 2.0.11 - - unist-util-visit-parents@5.1.3: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - - unist-util-visit@4.1.2: - dependencies: - '@types/unist': 2.0.11 - unist-util-is: 5.2.1 - unist-util-visit-parents: 5.1.3 - - universalify@2.0.1: {} - - unpipe@1.0.0: {} - - update-browserslist-db@1.2.3(browserslist@4.28.1): - dependencies: - browserslist: 4.28.1 - escalade: 3.2.0 - picocolors: 1.1.1 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - use-callback-ref@1.3.3(@types/react@18.3.28)(react@19.2.4): - dependencies: - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - use-sidecar@1.1.3(@types/react@18.3.28)(react@19.2.4): - dependencies: - detect-node-es: 1.1.0 - react: 19.2.4 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.28 - - util-deprecate@1.0.2: {} - - util@0.12.5: - dependencies: - inherits: 2.0.4 - is-arguments: 1.2.0 - is-generator-function: 1.1.2 - is-typed-array: 1.1.15 - which-typed-array: 1.1.20 - - utils-merge@1.0.1: {} - - uvu@0.5.6: - dependencies: - dequal: 2.0.3 - diff: 5.2.2 - kleur: 4.1.5 - sade: 1.8.1 - - valibot@1.2.0(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - - validate-npm-package-license@3.0.4: - dependencies: - spdx-correct: 3.2.0 - spdx-expression-parse: 3.0.1 - - validate-npm-package-name@5.0.1: {} - - vary@1.1.2: {} - - vfile-message@3.1.4: - dependencies: - '@types/unist': 2.0.11 - unist-util-stringify-position: 3.0.3 - - vfile@5.3.7: - dependencies: - '@types/unist': 2.0.11 - is-buffer: 2.0.5 - unist-util-stringify-position: 3.0.3 - vfile-message: 3.1.4 - - vite-node@1.6.1(@types/node@25.3.2)(lightningcss@1.31.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - pathe: 1.1.2 - picocolors: 1.1.1 - vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite-node@3.2.4(@types/node@25.3.2)(lightningcss@1.31.1): - dependencies: - cac: 6.7.14 - debug: 4.4.3 - es-module-lexer: 1.7.0 - pathe: 2.0.3 - vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) - transitivePeerDependencies: - - '@types/node' - - less - - lightningcss - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1): - dependencies: - esbuild: 0.21.5 - postcss: 8.5.6 - rollup: 4.59.0 - optionalDependencies: - '@types/node': 25.3.2 - fsevents: 2.3.3 - lightningcss: 1.31.1 - - wcwidth@1.0.1: - dependencies: - defaults: 1.0.4 - - web-encoding@1.1.5: - dependencies: - util: 0.12.5 - optionalDependencies: - '@zxing/text-encoding': 0.9.0 - - web-streams-polyfill@3.3.3: {} - - which-boxed-primitive@1.1.1: - dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 - - which-builtin-type@1.2.1: - dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - which@3.0.1: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.2.0 - - wrappy@1.0.2: {} - - ws@7.5.10: {} - - xtend@4.0.2: {} - - yallist@3.1.1: {} - - yallist@4.0.0: {} - - yaml@1.10.2: {} - - yaml@2.8.2: {} - - yocto-queue@0.1.0: {} - - zwitch@2.0.4: {} +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@radix-ui/react-checkbox': + specifier: ^1.0.0 + version: 1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': + specifier: ^1.0.0 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': + specifier: ^1.0.0 + version: 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': + specifier: ^2.0.0 + version: 2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': + specifier: ^1.0.0 + version: 1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': + specifier: ^1.0.0 + version: 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': + specifier: ^1.0.0 + version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + cmdk: + specifier: ^1.0.0 + version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + date-fns: + specifier: ^3.0.6 + version: 3.6.0 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + lucide-react: + specifier: ^0.563.0 + version: 0.563.0(react@19.2.4) + tailwind-merge: + specifier: ^3.4.0 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.2.1) + devDependencies: + '@monaco-editor/react': + specifier: ^4.6.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@playwright/test': + specifier: ^1.48.0 + version: 1.58.2 + '@rollup/plugin-commonjs': + specifier: ^25.0.7 + version: 25.0.8(rollup@4.59.0) + '@rollup/plugin-node-resolve': + specifier: ^15.2.3 + version: 15.3.1(rollup@4.59.0) + '@rollup/plugin-typescript': + specifier: ^11.1.6 + version: 11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3) + '@tailwindcss/postcss': + specifier: ^4.2.1 + version: 4.2.1 + '@types/react': + specifier: ^18.0.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.0.0 + version: 18.3.7(@types/react@18.3.28) + '@typescript-eslint/eslint-plugin': + specifier: ^6.15.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/parser': + specifier: ^6.15.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) + autoprefixer: + specifier: ^10.4.27 + version: 10.4.27(postcss@8.5.6) + eslint: + specifier: ^8.56.0 + version: 8.57.1 + eslint-plugin-react: + specifier: ^7.33.2 + version: 7.37.5(eslint@8.57.1) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.2(eslint@8.57.1) + monaco-editor: + specifier: ^0.52.0 + version: 0.52.2 + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + rollup: + specifier: ^4.9.1 + version: 4.59.0 + rollup-plugin-peer-deps-external: + specifier: ^2.2.4 + version: 2.2.4(rollup@4.59.0) + rollup-plugin-postcss: + specifier: ^4.0.2 + version: 4.0.2(postcss@8.5.6) + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + tslib: + specifier: ^2.6.2 + version: 2.8.1 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + + example: + dependencies: + '@datum-cloud/activity-ui': + specifier: workspace:* + version: link:.. + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@remix-run/node': + specifier: ^2.15.2 + version: 2.17.4(typescript@5.9.3) + '@remix-run/react': + specifier: ^2.15.2 + version: 2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@remix-run/serve': + specifier: ^2.15.2 + version: 2.17.4(typescript@5.9.3) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + isbot: + specifier: ^5.1.17 + version: 5.1.35 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + lucide-react: + specifier: ^0.555.0 + version: 0.555.0(react@18.3.1) + monaco-editor: + specifier: ^0.52.2 + version: 0.52.2 + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + tailwind-merge: + specifier: ^3.4.0 + version: 3.5.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.2.1) + devDependencies: + '@remix-run/dev': + specifier: ^2.15.2 + version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@remix-run/serve@2.17.4(typescript@5.9.3))(@types/node@25.3.2)(lightningcss@1.31.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1)) + '@tailwindcss/oxide-darwin-arm64': + specifier: ^4.2.1 + version: 4.2.1 + '@tailwindcss/postcss': + specifier: ^4.1.17 + version: 4.2.1 + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1)) + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/react': + specifier: ^18.2.45 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.7(@types/react@18.3.28) + autoprefixer: + specifier: ^10.4.22 + version: 10.4.27(postcss@8.5.6) + lightningcss: + specifier: ^1.31.1 + version: 1.31.1 + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^4.1.17 + version: 4.2.1 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vite: + specifier: ^5.0.8 + version: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-decorators@7.28.6': + resolution: {integrity: sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.17.6': + resolution: {integrity: sha512-YnYSCceN/dUzUr5kdtUzB+wZprCafuD89Hs0Aqv9QSdwhYQybhXTaSTcrl6X/aWThn1a/j0eEpUBGOE7269REg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.17.6': + resolution: {integrity: sha512-bSC9YVUjADDy1gae8RrioINU6e1lCkg3VGVwm0QQ2E1CWcC4gnMce9+B6RpxuSsrsXsk1yojn7sp1fnG8erE2g==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.17.6': + resolution: {integrity: sha512-MVcYcgSO7pfu/x34uX9u2QIZHmXAB7dEiLQC5bBl5Ryqtpj9lT2sg3gNDEsrPEmimSJW2FXIaxqSQ501YLDsZQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.17.6': + resolution: {integrity: sha512-bsDRvlbKMQMt6Wl08nHtFz++yoZHsyTOxnjfB2Q95gato+Yi4WnRl13oC2/PJJA9yLCoRv9gqT/EYX0/zDsyMA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.17.6': + resolution: {integrity: sha512-xh2A5oPrYRfMFz74QXIQTQo8uA+hYzGWJFoeTE8EvoZGHb+idyV4ATaukaUvnnxJiauhs/fPx3vYhU4wiGfosg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.17.6': + resolution: {integrity: sha512-EnUwjRc1inT4ccZh4pB3v1cIhohE2S4YXlt1OvI7sw/+pD+dIE4smwekZlEPIwY6PhU6oDWwITrQQm5S2/iZgg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.17.6': + resolution: {integrity: sha512-Uh3HLWGzH6FwpviUcLMKPCbZUAFzv67Wj5MTwK6jn89b576SR2IbEp+tqUHTr8DIl0iDmBAf51MVaP7pw6PY5Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.17.6': + resolution: {integrity: sha512-bUR58IFOMJX523aDVozswnlp5yry7+0cRLCXDsxnUeQYJik1DukMY+apBsLOZJblpH+K7ox7YrKrHmJoWqVR9w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.17.6': + resolution: {integrity: sha512-7YdGiurNt7lqO0Bf/U9/arrPWPqdPqcV6JCZda4LZgEn+PTQ5SMEI4MGR52Bfn3+d6bNEGcWFzlIxiQdS48YUw==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.17.6': + resolution: {integrity: sha512-ujp8uoQCM9FRcbDfkqECoARsLnLfCUhKARTP56TFPog8ie9JG83D5GVKjQ6yVrEVdMie1djH86fm98eY3quQkQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.17.6': + resolution: {integrity: sha512-y2NX1+X/Nt+izj9bLoiaYB9YXT/LoaQFYvCkVD77G/4F+/yuVXYCWz4SE9yr5CBMbOxOfBcy/xFL4LlOeNlzYQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.17.6': + resolution: {integrity: sha512-09AXKB1HDOzXD+j3FdXCiL/MWmZP0Ex9eR8DLMBVcHorrWJxWmY8Nms2Nm41iRM64WVx7bA/JVHMv081iP2kUA==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.17.6': + resolution: {integrity: sha512-AmLhMzkM8JuqTIOhxnX4ubh0XWJIznEynRnZAVdA2mMKE6FAfwT2TWKTwdqMG+qEaeyDPtfNoZRpJbD4ZBv0Tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.17.6': + resolution: {integrity: sha512-Y4Ri62PfavhLQhFbqucysHOmRamlTVK10zPWlqjNbj2XMea+BOs4w6ASKwQwAiqf9ZqcY9Ab7NOU4wIgpxwoSQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.17.6': + resolution: {integrity: sha512-SPUiz4fDbnNEm3JSdUW8pBJ/vkop3M1YwZAVwvdwlFLoJwKEZ9L98l3tzeyMzq27CyepDQ3Qgoba44StgbiN5Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.17.6': + resolution: {integrity: sha512-a3yHLmOodHrzuNgdpB7peFGPx1iJ2x6m+uDvhP2CKdr2CwOaqEFMeSqYAHU7hG+RjCq8r2NFujcd/YsEsFgTGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.17.6': + resolution: {integrity: sha512-EanJqcU/4uZIBreTrnbnre2DXgXSa+Gjap7ifRfllpmyAU7YMvaXmljdArptTHmjrkkKm9BK6GH5D5Yo+p6y5A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.17.6': + resolution: {integrity: sha512-xaxeSunhQRsTNGFanoOkkLtnmMn5QbA0qBhNet/XLVsc+OVkpIWPHcr3zTW2gxVU5YOHFbIHR9ODuaUdNza2Vw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.17.6': + resolution: {integrity: sha512-gnMnMPg5pfMkZvhHee21KbKdc6W3GR8/JuE0Da1kjwpK6oiFU3nqfHuVPgUX2rsOx9N2SadSQTIYV1CIjYG+xw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.17.6': + resolution: {integrity: sha512-G95n7vP1UnGJPsVdKXllAJPtqjMvFYbN20e8RK8LVLhlTiSOH1sd7+Gt7rm70xiG+I5tM58nYgwWrLs6I1jHqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.17.6': + resolution: {integrity: sha512-96yEFzLhq5bv9jJo5JhTs1gI+1cKQ83cUpyxHuGqXVwQtY5Eq54ZEsKs8veKtiKwlrNimtckHEkj4mRh4pPjsg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.17.6': + resolution: {integrity: sha512-n6d8MOyUrNp6G4VSpRcgjs5xj4A91svJSaiwLIDWVWEsZtpN5FA9NlBbZHDmAJc2e8e6SF4tkBD3HAvPF+7igA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jspm/core@2.1.0': + resolution: {integrity: sha512-3sRl+pkyFY/kLmHl0cgHiFp2xEqErA8N3ECjMs7serSUBmoJ70lBa0PG5t0IM6WJgdZNyyI0R8YFfi5wM8+mzg==} + + '@mdx-js/mdx@2.3.0': + resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} + + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@npmcli/fs@3.1.1': + resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/git@4.1.0': + resolution: {integrity: sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/package-json@4.0.1': + resolution: {integrity: sha512-lRCEGdHZomFsURroh522YvA/2cVb9oPIJrjHanCJZkiasz1BzcnLr3tBJhlV7S86MBJBuAQ33is2D60YitZL2Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@npmcli/promise-spawn@6.0.2': + resolution: {integrity: sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.4': + resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.8': + resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-slot@1.2.4': + resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@remix-run/dev@2.17.4': + resolution: {integrity: sha512-El7r5W6ErX9KIy27+urbc4SIZnIlVDgTOUqzA7Zbv7caKYrsvgj/Z3i/LPy4VNfv0G1EdawPOrygJgIKT4r2FA==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@remix-run/react': ^2.17.0 + '@remix-run/serve': ^2.17.0 + typescript: ^5.1.0 + vite: ^5.1.0 || ^6.0.0 + wrangler: ^3.28.2 + peerDependenciesMeta: + '@remix-run/serve': + optional: true + typescript: + optional: true + vite: + optional: true + wrangler: + optional: true + + '@remix-run/express@2.17.4': + resolution: {integrity: sha512-4zZs0L7v2pvAq896zHRLNMhoOKIPXM9qnYdHLbz4mpZUMbNAgQacGazArIrUV3M4g0gRMY0dLrt5CqMNrlBeYg==} + engines: {node: '>=18.0.0'} + peerDependencies: + express: ^4.20.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/node@2.17.4': + resolution: {integrity: sha512-9A29JaYiGHDEmaiQuD1IlO/TrQxnnkj98GpytihU+Nz6yTt6RwzzyMMqTAoasRd1dPD4OeSaSqbwkcim/eE76Q==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/react@2.17.4': + resolution: {integrity: sha512-MeXHacIBoohr9jzec5j/Rmk57xk34korkPDDb0OPHgkdvh20lO5fJoSAcnZfjTIOH+Vsq1ZRQlmvG5PRQ/64Sw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/router@1.23.2': + resolution: {integrity: sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==} + engines: {node: '>=14.0.0'} + + '@remix-run/serve@2.17.4': + resolution: {integrity: sha512-c632agTDib70cytmxMVqSbBMlhFKawcg5048yZZK/qeP2AmUweM7OY6Ivgcmv/pgjLXYOu17UBKhtGU8T5y8cQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + '@remix-run/server-runtime@2.17.4': + resolution: {integrity: sha512-oCsFbPuISgh8KpPKsfBChzjcntvTz5L+ggq9VNYWX8RX3yA7OgQpKspRHOSxb05bw7m0Hx+L1KRHXjf3juKX8w==} + engines: {node: '>=18.0.0'} + peerDependencies: + typescript: ^5.1.0 + peerDependenciesMeta: + typescript: + optional: true + + '@remix-run/web-blob@3.1.0': + resolution: {integrity: sha512-owGzFLbqPH9PlKb8KvpNJ0NO74HWE2euAn61eEiyCXX/oteoVzTVSN8mpLgDjaxBf2btj5/nUllSUgpyd6IH6g==} + + '@remix-run/web-fetch@4.4.2': + resolution: {integrity: sha512-jgKfzA713/4kAW/oZ4bC3MoLWyjModOVDjFPNseVqcJKSafgIscrYL9G50SurEYLswPuoU3HzSbO0jQCMYWHhA==} + engines: {node: ^10.17 || >=12.3} + + '@remix-run/web-file@3.1.0': + resolution: {integrity: sha512-dW2MNGwoiEYhlspOAXFBasmLeYshyAyhIdrlXBi06Duex5tDr3ut2LFKVj7tyHLmn8nnNwFf1BjNbkQpygC2aQ==} + + '@remix-run/web-form-data@3.1.0': + resolution: {integrity: sha512-NdeohLMdrb+pHxMQ/Geuzdp0eqPbea+Ieo8M8Jx2lGC6TBHsgHzYcBvr0LyPdPVycNRDEpWpiDdCOdCryo3f9A==} + + '@remix-run/web-stream@1.1.0': + resolution: {integrity: sha512-KRJtwrjRV5Bb+pM7zxcTJkhIqWWSy+MYsIxHK+0m5atcznsf15YwUBWHWulZerV2+vvHH1Lp1DD7pw6qKW8SgA==} + + '@rollup/plugin-commonjs@25.0.8': + resolution: {integrity: sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.68.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-node-resolve@15.3.1': + resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-typescript@11.1.6': + resolution: {integrity: sha512-R92yOmIACgYdJ7dJ97p4K69I8gg6IEHt8M7dUBxN3W6nrO8uUxX5ixl0yU/N3aZTi8WhPuICvOHXQvF6FaykAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.1': + resolution: {integrity: sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@trysound/sax@0.2.0': + resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} + engines: {node: '>=10.13.0'} + + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@2.3.10': + resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@3.0.15': + resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} + + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@25.3.2': + resolution: {integrity: sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.28': + resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@typescript-eslint/eslint-plugin@6.21.0': + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/parser@6.21.0': + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/scope-manager@6.21.0': + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/type-utils@6.21.0': + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/types@6.21.0': + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@typescript-eslint/typescript-estree@6.21.0': + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@typescript-eslint/utils@6.21.0': + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} + engines: {node: ^16.0.0 || >=18.0.0} + peerDependencies: + eslint: ^7.0.0 || ^8.0.0 + + '@typescript-eslint/visitor-keys@6.21.0': + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} + engines: {node: ^16.0.0 || >=18.0.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} + + '@vanilla-extract/css@1.18.0': + resolution: {integrity: sha512-/p0dwOjr0o8gE5BRQ5O9P0u/2DjUd6Zfga2JGmE4KaY7ZITWMszTzk4x4CPlM5cKkRr2ZGzbE6XkuPNfp9shSQ==} + + '@vanilla-extract/integration@6.5.0': + resolution: {integrity: sha512-E2YcfO8vA+vs+ua+gpvy1HRqvgWbI+MTlUpxA8FvatOvybuNcWAY0CKwQ/Gpj7rswYKtC6C7+xw33emM6/ImdQ==} + + '@vanilla-extract/private@1.0.9': + resolution: {integrity: sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==} + + '@web3-storage/multipart-parser@1.0.0': + resolution: {integrity: sha512-BEO6al7BYqcnfX15W2cnGR+Q566ACXAT9UQykORCWW80lmkpWsnEob6zJS1ZVBKsSJC8+7vJkHwlp+lXG1UCdw==} + + '@zxing/text-encoding@0.9.0': + resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@1.20.4: + resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacache@17.1.4: + resolution: {integrity: sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.8: + resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + + caniuse-lite@1.0.30001774: + resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + + compression@1.8.1: + resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + engines: {node: '>= 0.8.0'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-with-sourcemaps@1.1.0: + resolution: {integrity: sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-declaration-sorter@6.4.1: + resolution: {integrity: sha512-rtdthzxKuyq6IzqX6jEcIzQF/YqccluefyCYheovBOLhFT/drQA9zj/UbRAa9J7C0o6EG6u3E6g+vKkay7/k3g==} + engines: {node: ^10 || ^12 || >=14} + peerDependencies: + postcss: ^8.0.9 + + css-select@4.3.0: + resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} + + css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssnano-preset-default@5.2.14: + resolution: {integrity: sha512-t0SFesj/ZV2OTylqQVOrFgEh5uanxbO6ZAdeCrNsUQ6fVuXwYTxJPNAGvGTxHbD68ldIJNec7PyYZDBrfDQ+6A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + cssnano-utils@3.1.0: + resolution: {integrity: sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + cssnano@5.1.15: + resolution: {integrity: sha512-j+BKgDcLDQA+eDifLx0EO4XSA56b7uut3BQFH+wbSaSTuGLuiyTa/wbRYthUXX8LC9mLg+WWKe8h+qJuwTAbHw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + csso@4.2.0: + resolution: {integrity: sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA==} + engines: {node: '>=8.0.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@3.0.1: + resolution: {integrity: sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og==} + engines: {node: '>= 6'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + date-fns@3.6.0: + resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dedent@1.7.1: + resolution: {integrity: sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.19.0: + resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + engines: {node: '>=10.13.0'} + + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-abstract@1.24.1: + resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.2.2: + resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + esbuild-plugins-node-modules-polyfill@1.8.1: + resolution: {integrity: sha512-7vxzmyTFDhYUNhjlciMPmp32eUafNIHiXvo8ZD22PzccnxMoGpPnsYn17gSBoFZgpRYNdCJcAWsQ60YVKgKg3A==} + engines: {node: '>=14.0.0'} + peerDependencies: + esbuild: '>=0.14.0 <=0.27.x' + + esbuild@0.17.6: + resolution: {integrity: sha512-TKFRp9TxrJDdRWfSsSERKEovm6v30iHnrjlcGhLBOtReE28Yp1VSBRfO3GTaOFMoxsNerx4TjrhzSuma9ha83Q==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-attach-comments@2.1.1: + resolution: {integrity: sha512-+5Ba/xGGS6mnwFbXIuQiDPTbuTxuMCooq3arVv7gPZtYpjp+VXH/NkHAP35OOefPhNG/UGqU3vt/LTABwcHX0w==} + + estree-util-build-jsx@2.2.2: + resolution: {integrity: sha512-m56vOXcOBuaF+Igpb9OPAy7f9w9OIkb5yhjsZuaPm7HoGi4oTOQi0h2+yZ+AtKklYFZ+rPC4n0wYCJCEU1ONqg==} + + estree-util-is-identifier-name@1.1.0: + resolution: {integrity: sha512-OVJZ3fGGt9By77Ix9NhaRbzfbDV/2rx9EP7YIDJTmsZSEc5kYn2vWcNccYyahJL2uAQZK2a5Or2i0wtIKTPoRQ==} + + estree-util-is-identifier-name@2.1.0: + resolution: {integrity: sha512-bEN9VHRyXAUOjkKVQVvArFym08BTWB0aJPppZZr0UNyAqWsLaVfAqP7hbaTJjzHifmB5ebnR8Wm7r7yGN/HonQ==} + + estree-util-to-js@1.2.0: + resolution: {integrity: sha512-IzU74r1PK5IMMGZXUVZbmiu4A1uhiPgW5hm1GjcOfr4ZzHaMPpLNJjR7HjXiIOzi25nZDrgFTobHTkV5Q6ITjA==} + + estree-util-value-to-estree@1.3.0: + resolution: {integrity: sha512-Y+ughcF9jSUJvncXwqRageavjrNPAI+1M/L3BI3PyLp1nmgYTGUXU6t5z1Y7OWuThoDdhPME07bQU+d5LxdJqw==} + engines: {node: '>=12.0.0'} + + estree-util-visit@1.2.1: + resolution: {integrity: sha512-xbgqcrkIVbIG+lI/gzbvd9SGTJL4zqJKBFttUl5pP27KhAjtMKbX/mQXJ7qgyXpMgVy/zvpm0xoQQaGL8OloOw==} + + estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eval@0.1.8: + resolution: {integrity: sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==} + engines: {node: '>= 0.8'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + + express@4.22.1: + resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} + engines: {node: '>= 0.10.0'} + + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fault@2.0.1: + resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + format@0.2.2: + resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} + engines: {node: '>=0.4.x'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs-minipass@3.0.3: + resolution: {integrity: sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + generic-names@4.0.0: + resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-to-estree@2.3.3: + resolution: {integrity: sha512-ihhPIUPxN0v0w6M5+IiAZZrn0LH2uZomeWwhn7uP7avZC6TE7lIiEh2yBMPr5+zi1aUCXq6VoYRgs2Bw9xmycQ==} + + hast-util-whitespace@2.0.1: + resolution: {integrity: sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==} + + hosted-git-info@6.1.3: + resolution: {integrity: sha512-HVJyzUrLIL1c0QmviVh5E8VGyUS7xCFPS6yydaVd1UegW+ibV/CohqTH9MkOLDp5o+rb82DMo77PTuc9F/8GKw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + icss-replace-symbols@1.1.0: + resolution: {integrity: sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-cwd@3.0.0: + resolution: {integrity: sha512-4pnzH16plW+hgvRECbDWpQl3cqtvSofHWh44met7ESfZ8UZOWWddm8hEyDTqREJ9RbYHY8gi8DqmaelApoOGMg==} + engines: {node: '>=8'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-from@3.0.0: + resolution: {integrity: sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ==} + engines: {node: '>=8'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-buffer@2.0.5: + resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==} + engines: {node: '>=4'} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-plain-obj@3.0.0: + resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} + engines: {node: '>=10'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-reference@1.2.1: + resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isbot@5.1.35: + resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + javascript-stringify@2.1.0: + resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@3.0.2: + resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + loader-utils@3.3.1: + resolution: {integrity: sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==} + engines: {node: '>= 12.13.0'} + + local-pkg@1.1.2: + resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} + engines: {node: '>=14'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.uniq@4.5.0: + resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lucide-react@0.555.0: + resolution: {integrity: sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + lucide-react@0.563.0: + resolution: {integrity: sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-extensions@1.1.1: + resolution: {integrity: sha512-WWC0ZuMzCyDHYCasEGs4IPvLyTGftYwh6wIEOULOF0HXcqZlhwRzrK0w2VUlxWA98xnvb/jszw4ZSkJ6ADpM6Q==} + engines: {node: '>=0.10.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-definitions@5.1.2: + resolution: {integrity: sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==} + + mdast-util-from-markdown@1.3.1: + resolution: {integrity: sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==} + + mdast-util-frontmatter@1.0.1: + resolution: {integrity: sha512-JjA2OjxRqAa8wEG8hloD0uTU0kdn8kbtOWpPP94NBkfAlbxn4S8gCGf/9DwFtEeGPXrDcNXdiDjVaRdUFqYokw==} + + mdast-util-mdx-expression@1.3.2: + resolution: {integrity: sha512-xIPmR5ReJDu/DHH1OoIT1HkuybIfRGYRywC+gJtI7qHjCJp/M9jrmBEJW22O8lskDWm562BX2W8TiAwRTb0rKA==} + + mdast-util-mdx-jsx@2.1.4: + resolution: {integrity: sha512-DtMn9CmVhVzZx3f+optVDF8yFgQVt7FghCRNdlIaS3X5Bnym3hZwPbg/XW86vdpKjlc1PVj26SpnLGeJBXD3JA==} + + mdast-util-mdx@2.0.1: + resolution: {integrity: sha512-38w5y+r8nyKlGvNjSEqWrhG0w5PmnRA+wnBvm+ulYCct7nsGYhFVb0lljS9bQav4psDAS1eGkP2LMVcZBi/aqw==} + + mdast-util-mdxjs-esm@1.3.1: + resolution: {integrity: sha512-SXqglS0HrEvSdUEfoXFtcg7DRl7S2cwOXc7jkuusG472Mmjag34DUDeOJUZtl+BVnyeO1frIgVpHlNRWc2gk/w==} + + mdast-util-phrasing@3.0.1: + resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} + + mdast-util-to-hast@12.3.0: + resolution: {integrity: sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==} + + mdast-util-to-markdown@1.5.0: + resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} + + mdast-util-to-string@3.2.0: + resolution: {integrity: sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==} + + mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + + media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + micromark-core-commonmark@1.1.0: + resolution: {integrity: sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==} + + micromark-extension-frontmatter@1.1.1: + resolution: {integrity: sha512-m2UH9a7n3W8VAH9JO9y01APpPKmNNNs71P0RbknEmYSaZU5Ghogv38BYO94AI5Xw6OYfxZRdHZZ2nYjs/Z+SZQ==} + + micromark-extension-mdx-expression@1.0.8: + resolution: {integrity: sha512-zZpeQtc5wfWKdzDsHRBY003H2Smg+PUi2REhqgIhdzAa5xonhP03FcXxqFSerFiNUr5AWmHpaNPQTBVOS4lrXw==} + + micromark-extension-mdx-jsx@1.0.5: + resolution: {integrity: sha512-gPH+9ZdmDflbu19Xkb8+gheqEDqkSpdCEubQyxuz/Hn8DOXiXvrXeikOoBA71+e8Pfi0/UYmU3wW3H58kr7akA==} + + micromark-extension-mdx-md@1.0.1: + resolution: {integrity: sha512-7MSuj2S7xjOQXAjjkbjBsHkMtb+mDGVW6uI2dBL9snOBCbZmoNgDAeZ0nSn9j3T42UE/g2xVNMn18PJxZvkBEA==} + + micromark-extension-mdxjs-esm@1.0.5: + resolution: {integrity: sha512-xNRBw4aoURcyz/S69B19WnZAkWJMxHMT5hE36GtDAyhoyn/8TuAeqjFJQlwk+MKQsUD7b3l7kFX+vlfVWgcX1w==} + + micromark-extension-mdxjs@1.0.1: + resolution: {integrity: sha512-7YA7hF6i5eKOfFUzZ+0z6avRG52GpWR8DL+kN47y3f2KhxbBZMhmxe7auOeaTBrW2DenbbZTf1ea9tA2hDpC2Q==} + + micromark-factory-destination@1.1.0: + resolution: {integrity: sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==} + + micromark-factory-label@1.1.0: + resolution: {integrity: sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==} + + micromark-factory-mdx-expression@1.0.9: + resolution: {integrity: sha512-jGIWzSmNfdnkJq05c7b0+Wv0Kfz3NJ3N4cBjnbO4zjXIlxJr+f8lk+5ZmwFvqdAbUy2q6B5rCY//g0QAAaXDWA==} + + micromark-factory-space@1.1.0: + resolution: {integrity: sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==} + + micromark-factory-title@1.1.0: + resolution: {integrity: sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==} + + micromark-factory-whitespace@1.1.0: + resolution: {integrity: sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==} + + micromark-util-character@1.2.0: + resolution: {integrity: sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==} + + micromark-util-chunked@1.1.0: + resolution: {integrity: sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==} + + micromark-util-classify-character@1.1.0: + resolution: {integrity: sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==} + + micromark-util-combine-extensions@1.1.0: + resolution: {integrity: sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==} + + micromark-util-decode-numeric-character-reference@1.1.0: + resolution: {integrity: sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==} + + micromark-util-decode-string@1.1.0: + resolution: {integrity: sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==} + + micromark-util-encode@1.1.0: + resolution: {integrity: sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==} + + micromark-util-events-to-acorn@1.2.3: + resolution: {integrity: sha512-ij4X7Wuc4fED6UoLWkmo0xJQhsktfNh1J0m8g4PbIMPlx+ek/4YdW5mvbye8z/aZvAPUoxgXHrwVlXAPKMRp1w==} + + micromark-util-html-tag-name@1.2.0: + resolution: {integrity: sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==} + + micromark-util-normalize-identifier@1.1.0: + resolution: {integrity: sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==} + + micromark-util-resolve-all@1.1.0: + resolution: {integrity: sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==} + + micromark-util-sanitize-uri@1.2.0: + resolution: {integrity: sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==} + + micromark-util-subtokenize@1.1.0: + resolution: {integrity: sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==} + + micromark-util-symbol@1.1.0: + resolution: {integrity: sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==} + + micromark-util-types@1.1.0: + resolution: {integrity: sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==} + + micromark@3.2.0: + resolution: {integrity: sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-flush@1.0.5: + resolution: {integrity: sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + modern-ahocorasick@1.1.0: + resolution: {integrity: sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==} + + monaco-editor@0.52.2: + resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==} + + morgan@1.10.1: + resolution: {integrity: sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==} + engines: {node: '>= 0.8.0'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@1.0.1: + resolution: {integrity: sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==} + engines: {node: '>=10'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-package-data@5.0.0: + resolution: {integrity: sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + npm-install-checks@6.3.0: + resolution: {integrity: sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-normalize-package-bin@3.0.1: + resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-package-arg@10.1.0: + resolution: {integrity: sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-pick-manifest@8.0.2: + resolution: {integrity: sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + outdent@0.8.0: + resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-ms@2.1.0: + resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pidtree@0.6.0: + resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==} + engines: {node: '>=0.10'} + hasBin: true + + pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss-calc@8.2.4: + resolution: {integrity: sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q==} + peerDependencies: + postcss: ^8.2.2 + + postcss-colormin@5.3.1: + resolution: {integrity: sha512-UsWQG0AqTFQmpBegeLLc1+c3jIqBNB0zlDGRWR+dQ3pRKJL1oeMzyqmH3o2PIfn9MBdNrVPWhDbT769LxCTLJQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-convert-values@5.1.3: + resolution: {integrity: sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-comments@5.1.2: + resolution: {integrity: sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-duplicates@5.1.0: + resolution: {integrity: sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-empty@5.1.1: + resolution: {integrity: sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-discard-overridden@5.1.0: + resolution: {integrity: sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-load-config@3.1.4: + resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} + engines: {node: '>= 10'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-load-config@4.0.2: + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + + postcss-merge-longhand@5.1.7: + resolution: {integrity: sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-merge-rules@5.1.4: + resolution: {integrity: sha512-0R2IuYpgU93y9lhVbO/OylTtKMVcHb67zjWIfCiKR9rWL3GUk1677LAqD/BcHizukdZEjT8Ru3oHRoAYoJy44g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-font-values@5.1.0: + resolution: {integrity: sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-gradients@5.1.1: + resolution: {integrity: sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-params@5.1.4: + resolution: {integrity: sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-minify-selectors@5.2.1: + resolution: {integrity: sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules@4.3.1: + resolution: {integrity: sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==} + peerDependencies: + postcss: ^8.0.0 + + postcss-modules@6.0.1: + resolution: {integrity: sha512-zyo2sAkVvuZFFy0gc2+4O+xar5dYlaVy/ebO24KT0ftk/iJevSNyPyQellsBLlnccwh7f6V6Y4GvuKRYToNgpQ==} + peerDependencies: + postcss: ^8.0.0 + + postcss-normalize-charset@5.1.0: + resolution: {integrity: sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-display-values@5.1.0: + resolution: {integrity: sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-positions@5.1.1: + resolution: {integrity: sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-repeat-style@5.1.1: + resolution: {integrity: sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-string@5.1.0: + resolution: {integrity: sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-timing-functions@5.1.0: + resolution: {integrity: sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-unicode@5.1.1: + resolution: {integrity: sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-url@5.1.0: + resolution: {integrity: sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-normalize-whitespace@5.1.1: + resolution: {integrity: sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-ordered-values@5.1.3: + resolution: {integrity: sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-reduce-initial@5.1.2: + resolution: {integrity: sha512-dE/y2XRaqAi6OvjzD22pjTUQ8eOfc6m/natGHgKFBK9DxFmIm69YmaRVQrGgFlEfc1HePIurY0TmDeROK05rIg==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-reduce-transforms@5.1.0: + resolution: {integrity: sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-svgo@5.1.0: + resolution: {integrity: sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-unique-selectors@5.1.1: + resolution: {integrity: sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + pretty-ms@7.0.1: + resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} + engines: {node: '>=10'} + + proc-log@3.0.0: + resolution: {integrity: sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + promise.series@0.2.0: + resolution: {integrity: sha512-VWQJyU2bcDTgZw8kpfBpB/ejZASlCrzwz5f2hjb/zlujOEB4oeiAhHygAWq8ubsX2GVkD4kCU5V2dwOTaCY5EQ==} + engines: {node: '>=0.12'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@6.30.3: + resolution: {integrity: sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + + react-router@6.30.3: + resolution: {integrity: sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: '>=16.8' + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + remark-frontmatter@4.0.1: + resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} + + remark-mdx-frontmatter@1.1.1: + resolution: {integrity: sha512-7teX9DW4tI2WZkXS4DBxneYSY7NHiXl4AKdWDO9LXVweULlCT8OPWsOjLEnMIXViN1j+QcY8mfbq3k0EK6x3uA==} + engines: {node: '>=12.2.0'} + + remark-mdx@2.3.0: + resolution: {integrity: sha512-g53hMkpM0I98MU266IzDFMrTD980gNF3BJnkyFcmN+dD873mQeD5rdMO3Y2X+x8umQfbSE0PcoEDl7ledSA+2g==} + + remark-parse@10.0.2: + resolution: {integrity: sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==} + + remark-rehype@10.1.0: + resolution: {integrity: sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==} + + require-like@0.1.2: + resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + rollup-plugin-peer-deps-external@2.2.4: + resolution: {integrity: sha512-AWdukIM1+k5JDdAqV/Cxd+nejvno2FVLVeZ74NKggm3Q5s9cbbcOgUPGdbxPi4BXu7xGaZ8HG12F+thImYu/0g==} + peerDependencies: + rollup: '*' + + rollup-plugin-postcss@4.0.2: + resolution: {integrity: sha512-05EaY6zvZdmvPUDi3uCcAQoESDcYnv8ogJJQRp6V5kZ6J6P7uAVJlrTZcaaA20wTH527YTnKfkAoPxWI/jPp4w==} + engines: {node: '>=10'} + peerDependencies: + postcss: 8.x + + rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safe-array-concat@1.1.3: + resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + engines: {node: '>=0.4'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-identifier@0.4.2: + resolution: {integrity: sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w==} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + + spdx-exceptions@2.5.0: + resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} + + spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + + spdx-license-ids@3.0.23: + resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==} + + ssri@10.0.6: + resolution: {integrity: sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + stable@0.1.8: + resolution: {integrity: sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==} + deprecated: 'Modern JS already guarantees Array#sort() is a stable sort, so this library is deprecated. See the compatibility table on MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#browser_compatibility' + + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + stream-shift@1.0.3: + resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==} + + stream-slice@0.1.2: + resolution: {integrity: sha512-QzQxpoacatkreL6jsxnVb7X5R/pGw9OUv2qWTYWnmLpg4NdN31snPy/f3TdQE1ZUXaThRvj1Zw4/OGg0ZkaLMA==} + + string-hash@1.1.3: + resolution: {integrity: sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-inject@0.3.0: + resolution: {integrity: sha512-IezA2qp+vcdlhJaVm5SOdPPTUu0FCEqfNSli2vRuSIBbu5Nq5UvygTk/VzeCqfLz2Atj3dVII5QBKGZRZ0edzw==} + + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + + stylehacks@5.1.1: + resolution: {integrity: sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw==} + engines: {node: ^10 || ^12 || >=14.0} + peerDependencies: + postcss: ^8.2.15 + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + svgo@2.8.0: + resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} + engines: {node: '>=10.13.0'} + hasBin: true + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss-animate@1.0.7: + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + + through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + toml@3.0.0: + resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@1.4.3: + resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + turbo-stream@2.4.1: + resolution: {integrity: sha512-v8kOJXpG3WoTN/+at8vK7erSzo6nW6CIaeOvNOkHQVDajfz1ZVeSxCbc6tOH4hrGZW7VUCV0TOXd8CPzYnYkrw==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@6.23.0: + resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} + engines: {node: '>=18.17'} + + unified@10.1.2: + resolution: {integrity: sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==} + + unique-filename@3.0.0: + resolution: {integrity: sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + unique-slug@4.0.0: + resolution: {integrity: sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + unist-util-generated@2.0.1: + resolution: {integrity: sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==} + + unist-util-is@5.2.1: + resolution: {integrity: sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==} + + unist-util-position-from-estree@1.1.2: + resolution: {integrity: sha512-poZa0eXpS+/XpoQwGwl79UUdea4ol2ZuCYguVaJS4qzIOMDzbqz8a3erUCOmubSZkaOuGamb3tX790iwOIROww==} + + unist-util-position@4.0.4: + resolution: {integrity: sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==} + + unist-util-remove-position@4.0.2: + resolution: {integrity: sha512-TkBb0HABNmxzAcfLf4qsIbFbaPDvMO6wa3b3j4VcEzFVaw1LBKwnW4/sRJ/atSLSzoIg41JWEdnE7N6DIhGDGQ==} + + unist-util-stringify-position@3.0.3: + resolution: {integrity: sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==} + + unist-util-visit-parents@5.1.3: + resolution: {integrity: sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==} + + unist-util-visit@4.1.2: + resolution: {integrity: sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uvu@0.5.6: + resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} + engines: {node: '>=8'} + hasBin: true + + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-message@3.1.4: + resolution: {integrity: sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==} + + vfile@5.3.7: + resolution: {integrity: sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==} + + vite-node@1.6.1: + resolution: {integrity: sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + web-encoding@1.1.5: + resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@3.0.1: + resolution: {integrity: sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.10: + resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yaml@2.8.2: + resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.0.2 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emotion/hash@0.9.2': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.17.6': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.17.6': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.17.6': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.17.6': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.17.6': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.17.6': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.17.6': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.17.6': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.17.6': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.17.6': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.17.6': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.17.6': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.17.6': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.17.6': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.17.6': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.17.6': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.17.6': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.17.6': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.17.6': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.17.6': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.17.6': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.17.6': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.10': {} + + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jspm/core@2.1.0': {} + + '@mdx-js/mdx@2.3.0': + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/mdx': 2.0.13 + estree-util-build-jsx: 2.2.2 + estree-util-is-identifier-name: 2.1.0 + estree-util-to-js: 1.2.0 + estree-walker: 3.0.3 + hast-util-to-estree: 2.3.3 + markdown-extensions: 1.1.1 + periscopic: 3.1.0 + remark-mdx: 2.3.0 + remark-parse: 10.0.2 + remark-rehype: 10.1.0 + unified: 10.1.2 + unist-util-position-from-estree: 1.1.2 + unist-util-stringify-position: 3.0.3 + unist-util-visit: 4.1.2 + vfile: 5.3.7 + transitivePeerDependencies: + - supports-color + + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.52.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@monaco-editor/react@4.7.0(monaco-editor@0.52.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.52.2 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@npmcli/fs@3.1.1': + dependencies: + semver: 7.7.4 + + '@npmcli/git@4.1.0': + dependencies: + '@npmcli/promise-spawn': 6.0.2 + lru-cache: 7.18.3 + npm-pick-manifest: 8.0.2 + proc-log: 3.0.0 + promise-inflight: 1.0.1 + promise-retry: 2.0.1 + semver: 7.7.4 + which: 3.0.1 + transitivePeerDependencies: + - bluebird + + '@npmcli/package-json@4.0.1': + dependencies: + '@npmcli/git': 4.1.0 + glob: 10.5.0 + hosted-git-info: 6.1.3 + json-parse-even-better-errors: 3.0.2 + normalize-package-data: 5.0.0 + proc-log: 3.0.0 + semver: 7.7.4 + transitivePeerDependencies: + - bluebird + + '@npmcli/promise-spawn@6.0.2': + dependencies: + which: 3.0.1 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-context@1.1.2(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-direction@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-id@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-popover@1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.4(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-select@2.2.6(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@18.3.28)(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-slot@1.2.3(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-rect@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.28)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.28)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 18.3.28 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@radix-ui/rect@1.1.1': {} + + '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3))(@remix-run/serve@2.17.4(typescript@5.9.3))(@types/node@25.3.2)(lightningcss@1.31.1)(typescript@5.9.3)(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1))': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@mdx-js/mdx': 2.3.0 + '@npmcli/package-json': 4.0.1 + '@remix-run/node': 2.17.4(typescript@5.9.3) + '@remix-run/react': 2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@remix-run/router': 1.23.2 + '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) + '@types/mdx': 2.0.13 + '@vanilla-extract/integration': 6.5.0(@types/node@25.3.2)(lightningcss@1.31.1) + arg: 5.0.2 + cacache: 17.1.4 + chalk: 4.1.2 + chokidar: 3.6.0 + cross-spawn: 7.0.6 + dotenv: 16.6.1 + es-module-lexer: 1.7.0 + esbuild: 0.17.6 + esbuild-plugins-node-modules-polyfill: 1.8.1(esbuild@0.17.6) + execa: 5.1.1 + exit-hook: 2.2.1 + express: 4.22.1 + fs-extra: 10.1.0 + get-port: 5.1.1 + gunzip-maybe: 1.4.2 + jsesc: 3.0.2 + json5: 2.2.3 + lodash: 4.17.23 + lodash.debounce: 4.0.8 + minimatch: 9.0.9 + ora: 5.4.1 + pathe: 1.1.2 + picocolors: 1.1.1 + picomatch: 2.3.1 + pidtree: 0.6.0 + postcss: 8.5.6 + postcss-discard-duplicates: 5.1.0(postcss@8.5.6) + postcss-load-config: 4.0.2(postcss@8.5.6) + postcss-modules: 6.0.1(postcss@8.5.6) + prettier: 2.8.8 + pretty-ms: 7.0.1 + react-refresh: 0.14.2 + remark-frontmatter: 4.0.1 + remark-mdx-frontmatter: 1.1.1 + semver: 7.7.4 + set-cookie-parser: 2.7.2 + tar-fs: 2.1.4 + tsconfig-paths: 4.2.0 + valibot: 1.2.0(typescript@5.9.3) + vite-node: 3.2.4(@types/node@25.3.2)(lightningcss@1.31.1) + ws: 7.5.10 + optionalDependencies: + '@remix-run/serve': 2.17.4(typescript@5.9.3) + typescript: 5.9.3 + vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - bluebird + - bufferutil + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - ts-node + - utf-8-validate + + '@remix-run/express@2.17.4(express@4.22.1)(typescript@5.9.3)': + dependencies: + '@remix-run/node': 2.17.4(typescript@5.9.3) + express: 4.22.1 + optionalDependencies: + typescript: 5.9.3 + + '@remix-run/node@2.17.4(typescript@5.9.3)': + dependencies: + '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) + '@remix-run/web-fetch': 4.4.2 + '@web3-storage/multipart-parser': 1.0.0 + cookie-signature: 1.2.2 + source-map-support: 0.5.21 + stream-slice: 0.1.2 + undici: 6.23.0 + optionalDependencies: + typescript: 5.9.3 + + '@remix-run/react@2.17.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)': + dependencies: + '@remix-run/router': 1.23.2 + '@remix-run/server-runtime': 2.17.4(typescript@5.9.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + react-router-dom: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + turbo-stream: 2.4.1 + optionalDependencies: + typescript: 5.9.3 + + '@remix-run/router@1.23.2': {} + + '@remix-run/serve@2.17.4(typescript@5.9.3)': + dependencies: + '@remix-run/express': 2.17.4(express@4.22.1)(typescript@5.9.3) + '@remix-run/node': 2.17.4(typescript@5.9.3) + chokidar: 3.6.0 + compression: 1.8.1 + express: 4.22.1 + get-port: 5.1.1 + morgan: 1.10.1 + source-map-support: 0.5.21 + transitivePeerDependencies: + - supports-color + - typescript + + '@remix-run/server-runtime@2.17.4(typescript@5.9.3)': + dependencies: + '@remix-run/router': 1.23.2 + '@types/cookie': 0.6.0 + '@web3-storage/multipart-parser': 1.0.0 + cookie: 0.7.2 + set-cookie-parser: 2.7.2 + source-map: 0.7.6 + turbo-stream: 2.4.1 + optionalDependencies: + typescript: 5.9.3 + + '@remix-run/web-blob@3.1.0': + dependencies: + '@remix-run/web-stream': 1.1.0 + web-encoding: 1.1.5 + + '@remix-run/web-fetch@4.4.2': + dependencies: + '@remix-run/web-blob': 3.1.0 + '@remix-run/web-file': 3.1.0 + '@remix-run/web-form-data': 3.1.0 + '@remix-run/web-stream': 1.1.0 + '@web3-storage/multipart-parser': 1.0.0 + abort-controller: 3.0.0 + data-uri-to-buffer: 3.0.1 + mrmime: 1.0.1 + + '@remix-run/web-file@3.1.0': + dependencies: + '@remix-run/web-blob': 3.1.0 + + '@remix-run/web-form-data@3.1.0': + dependencies: + web-encoding: 1.1.5 + + '@remix-run/web-stream@1.1.0': + dependencies: + web-streams-polyfill: 3.3.3 + + '@rollup/plugin-commonjs@25.0.8(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + commondir: 1.0.1 + estree-walker: 2.0.2 + glob: 8.1.0 + is-reference: 1.2.1 + magic-string: 0.30.21 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-node-resolve@15.3.1(rollup@4.59.0)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.11 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/plugin-typescript@11.1.6(rollup@4.59.0)(tslib@2.8.1)(typescript@5.9.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.59.0) + resolve: 1.22.11 + typescript: 5.9.3 + optionalDependencies: + rollup: 4.59.0 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.59.0)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.59.0 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.19.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': {} + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/postcss@4.2.1': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + postcss: 8.5.6 + tailwindcss: 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + + '@trysound/sax@0.2.0': {} + + '@types/acorn@4.0.6': + dependencies: + '@types/estree': 1.0.8 + + '@types/cookie@0.6.0': {} + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@2.3.10': + dependencies: + '@types/unist': 2.0.11 + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/mdast@3.0.15': + dependencies: + '@types/unist': 2.0.11 + + '@types/mdx@2.0.13': {} + + '@types/ms@2.1.0': {} + + '@types/node@25.3.2': + dependencies: + undici-types: 7.18.2 + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.28)': + dependencies: + '@types/react': 18.3.28 + + '@types/react@18.3.28': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/resolve@1.20.2': {} + + '@types/semver@7.7.1': {} + + '@types/unist@2.0.11': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + semver: 7.7.4 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + eslint: 8.57.1 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@6.21.0': {} + + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.3 + semver: 7.7.4 + ts-api-utils: 1.4.3(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.7.1 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/visitor-keys@6.21.0': + dependencies: + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 + + '@ungap/structured-clone@1.3.0': {} + + '@vanilla-extract/babel-plugin-debug-ids@1.2.2': + dependencies: + '@babel/core': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@vanilla-extract/css@1.18.0': + dependencies: + '@emotion/hash': 0.9.2 + '@vanilla-extract/private': 1.0.9 + css-what: 6.2.2 + cssesc: 3.0.0 + csstype: 3.2.3 + dedent: 1.7.1 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + lru-cache: 10.4.3 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.1.0 + picocolors: 1.1.1 + transitivePeerDependencies: + - babel-plugin-macros + + '@vanilla-extract/integration@6.5.0(@types/node@25.3.2)(lightningcss@1.31.1)': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@vanilla-extract/babel-plugin-debug-ids': 1.2.2 + '@vanilla-extract/css': 1.18.0 + esbuild: 0.17.6 + eval: 0.1.8 + find-up: 5.0.0 + javascript-stringify: 2.1.0 + lodash: 4.17.23 + mlly: 1.8.0 + outdent: 0.8.0 + vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + vite-node: 1.6.1(@types/node@25.3.2)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + '@vanilla-extract/private@1.0.9': {} + + '@web3-storage/multipart-parser@1.0.0': {} + + '@zxing/text-encoding@0.9.0': + optional: true + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-flatten@1.1.1: {} + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array-union@2.1.0: {} + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + astring@1.9.0: {} + + async-function@1.0.0: {} + + autoprefixer@10.4.27(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001774 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.0: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + binary-extensions@2.3.0: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@1.20.4: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + boolbase@1.0.0: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserify-zlib@0.1.4: + dependencies: + pako: 0.2.9 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001774 + electron-to-chromium: 1.5.302 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + cacache@17.1.4: + dependencies: + '@npmcli/fs': 3.1.1 + fs-minipass: 3.0.3 + glob: 10.5.0 + lru-cache: 7.18.3 + minipass: 7.1.3 + minipass-collect: 1.0.2 + minipass-flush: 1.0.5 + minipass-pipeline: 1.2.4 + p-map: 4.0.0 + ssri: 10.0.6 + tar: 6.2.1 + unique-filename: 3.0.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.8: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001774 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + + caniuse-lite@1.0.30001774: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + chownr@2.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + clone@1.0.4: {} + + clsx@2.1.1: {} + + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commondir@1.0.1: {} + + compressible@2.0.18: + dependencies: + mime-db: 1.54.0 + + compression@1.8.1: + dependencies: + bytes: 3.1.2 + compressible: 2.0.18 + debug: 2.6.9 + negotiator: 0.6.4 + on-headers: 1.1.0 + safe-buffer: 5.2.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + concat-map@0.0.1: {} + + concat-with-sourcemaps@1.1.0: + dependencies: + source-map: 0.6.1 + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + core-util-is@1.0.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-declaration-sorter@6.4.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + css-select@4.3.0: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 4.3.1 + domutils: 2.8.0 + nth-check: 2.1.1 + + css-tree@1.1.3: + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + cssnano-preset-default@5.2.14(postcss@8.5.6): + dependencies: + css-declaration-sorter: 6.4.1(postcss@8.5.6) + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-calc: 8.2.4(postcss@8.5.6) + postcss-colormin: 5.3.1(postcss@8.5.6) + postcss-convert-values: 5.1.3(postcss@8.5.6) + postcss-discard-comments: 5.1.2(postcss@8.5.6) + postcss-discard-duplicates: 5.1.0(postcss@8.5.6) + postcss-discard-empty: 5.1.1(postcss@8.5.6) + postcss-discard-overridden: 5.1.0(postcss@8.5.6) + postcss-merge-longhand: 5.1.7(postcss@8.5.6) + postcss-merge-rules: 5.1.4(postcss@8.5.6) + postcss-minify-font-values: 5.1.0(postcss@8.5.6) + postcss-minify-gradients: 5.1.1(postcss@8.5.6) + postcss-minify-params: 5.1.4(postcss@8.5.6) + postcss-minify-selectors: 5.2.1(postcss@8.5.6) + postcss-normalize-charset: 5.1.0(postcss@8.5.6) + postcss-normalize-display-values: 5.1.0(postcss@8.5.6) + postcss-normalize-positions: 5.1.1(postcss@8.5.6) + postcss-normalize-repeat-style: 5.1.1(postcss@8.5.6) + postcss-normalize-string: 5.1.0(postcss@8.5.6) + postcss-normalize-timing-functions: 5.1.0(postcss@8.5.6) + postcss-normalize-unicode: 5.1.1(postcss@8.5.6) + postcss-normalize-url: 5.1.0(postcss@8.5.6) + postcss-normalize-whitespace: 5.1.1(postcss@8.5.6) + postcss-ordered-values: 5.1.3(postcss@8.5.6) + postcss-reduce-initial: 5.1.2(postcss@8.5.6) + postcss-reduce-transforms: 5.1.0(postcss@8.5.6) + postcss-svgo: 5.1.0(postcss@8.5.6) + postcss-unique-selectors: 5.1.1(postcss@8.5.6) + + cssnano-utils@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + cssnano@5.1.15(postcss@8.5.6): + dependencies: + cssnano-preset-default: 5.2.14(postcss@8.5.6) + lilconfig: 2.1.0 + postcss: 8.5.6 + yaml: 1.10.2 + + csso@4.2.0: + dependencies: + css-tree: 1.1.3 + + csstype@3.2.3: {} + + data-uri-to-buffer@3.0.1: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + date-fns@3.6.0: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dedent@1.7.1: {} + + deep-is@0.1.4: {} + + deep-object-diff@1.1.9: {} + + deepmerge@4.3.1: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + depd@2.0.0: {} + + dequal@2.0.3: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@5.2.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + + domelementtype@2.3.0: {} + + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + duplexify@3.7.1: + dependencies: + end-of-stream: 1.4.5 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.3 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.302: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.19.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@2.2.0: {} + + err-code@2.0.3: {} + + es-abstract@1.24.1: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.3 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.2.2: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + safe-array-concat: 1.1.3 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.2 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild-plugins-node-modules-polyfill@1.8.1(esbuild@0.17.6): + dependencies: + '@jspm/core': 2.1.0 + esbuild: 0.17.6 + local-pkg: 1.1.2 + resolve.exports: 2.0.3 + + esbuild@0.17.6: + optionalDependencies: + '@esbuild/android-arm': 0.17.6 + '@esbuild/android-arm64': 0.17.6 + '@esbuild/android-x64': 0.17.6 + '@esbuild/darwin-arm64': 0.17.6 + '@esbuild/darwin-x64': 0.17.6 + '@esbuild/freebsd-arm64': 0.17.6 + '@esbuild/freebsd-x64': 0.17.6 + '@esbuild/linux-arm': 0.17.6 + '@esbuild/linux-arm64': 0.17.6 + '@esbuild/linux-ia32': 0.17.6 + '@esbuild/linux-loong64': 0.17.6 + '@esbuild/linux-mips64el': 0.17.6 + '@esbuild/linux-ppc64': 0.17.6 + '@esbuild/linux-riscv64': 0.17.6 + '@esbuild/linux-s390x': 0.17.6 + '@esbuild/linux-x64': 0.17.6 + '@esbuild/netbsd-x64': 0.17.6 + '@esbuild/openbsd-x64': 0.17.6 + '@esbuild/sunos-x64': 0.17.6 + '@esbuild/win32-arm64': 0.17.6 + '@esbuild/win32-ia32': 0.17.6 + '@esbuild/win32-x64': 0.17.6 + + 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 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + dependencies: + eslint: 8.57.1 + + eslint-plugin-react@7.37.5(eslint@8.57.1): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.2.2 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.3.0 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.1 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@9.6.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-attach-comments@2.1.1: + dependencies: + '@types/estree': 1.0.8 + + estree-util-build-jsx@2.2.2: + dependencies: + '@types/estree-jsx': 1.0.5 + estree-util-is-identifier-name: 2.1.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@1.1.0: {} + + estree-util-is-identifier-name@2.1.0: {} + + estree-util-to-js@1.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-value-to-estree@1.3.0: + dependencies: + is-plain-obj: 3.0.0 + + estree-util-visit@1.2.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 2.0.11 + + estree-walker@0.6.1: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eval@0.1.8: + dependencies: + '@types/node': 25.3.2 + require-like: 0.1.2 + + event-target-shim@5.0.1: {} + + eventemitter3@4.0.7: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-hook@2.2.1: {} + + express@4.22.1: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.4 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + exsolve@1.0.8: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fault@2.0.1: + dependencies: + format: 0.2.2 + + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.3.3: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + format@0.2.2: {} + + forwarded@0.2.0: {} + + fraction.js@5.3.4: {} + + fresh@0.5.2: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs-minipass@3.0.3: + dependencies: + minipass: 7.1.3 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.2 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + generic-names@4.0.0: + dependencies: + loader-utils: 3.3.1 + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-port@5.1.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + gunzip-maybe@1.4.2: + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-to-estree@2.3.3: + dependencies: + '@types/estree': 1.0.8 + '@types/estree-jsx': 1.0.5 + '@types/hast': 2.3.10 + '@types/unist': 2.0.11 + comma-separated-tokens: 2.0.3 + estree-util-attach-comments: 2.1.1 + estree-util-is-identifier-name: 2.1.0 + hast-util-whitespace: 2.0.1 + mdast-util-mdx-expression: 1.3.2 + mdast-util-mdxjs-esm: 1.3.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 4.0.4 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@2.0.1: {} + + hosted-git-info@6.1.3: + dependencies: + lru-cache: 7.18.3 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + human-signals@2.1.0: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + icss-replace-symbols@1.1.0: {} + + icss-utils@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + import-cwd@3.0.0: + dependencies: + import-from: 3.0.0 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-from@3.0.0: + dependencies: + resolve-from: 5.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + inline-style-parser@0.1.1: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.1.0 + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-buffer@2.0.5: {} + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-decimal@2.0.1: {} + + is-deflate@1.0.0: {} + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-gzip@1.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-interactive@1.0.0: {} + + is-map@2.0.3: {} + + is-module@1.0.0: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-path-inside@3.0.3: {} + + is-plain-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-reference@1.2.1: + dependencies: + '@types/estree': 1.0.8 + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-stream@2.0.1: {} + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-unicode-supported@0.1.0: {} + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@1.0.0: {} + + isarray@2.0.5: {} + + isbot@5.1.35: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + javascript-stringify@2.1.0: {} + + jiti@2.6.1: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.0.2: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@3.0.2: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lilconfig@2.1.0: {} + + lilconfig@3.1.3: {} + + loader-utils@3.3.1: {} + + local-pkg@1.1.2: + dependencies: + mlly: 1.8.0 + pkg-types: 2.3.0 + quansync: 0.2.11 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.debounce@4.0.8: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lodash.uniq@4.5.0: {} + + lodash@4.17.23: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + longest-streak@3.1.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@7.18.3: {} + + lucide-react@0.555.0(react@18.3.1): + dependencies: + react: 18.3.1 + + lucide-react@0.563.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-extensions@1.1.1: {} + + math-intrinsics@1.1.0: {} + + mdast-util-definitions@5.1.2: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + unist-util-visit: 4.1.2 + + mdast-util-from-markdown@1.3.1: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + decode-named-character-reference: 1.3.0 + mdast-util-to-string: 3.2.0 + micromark: 3.2.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-decode-string: 1.1.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-stringify-position: 3.0.3 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + + mdast-util-frontmatter@1.0.1: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-to-markdown: 1.5.0 + micromark-extension-frontmatter: 1.1.1 + + mdast-util-mdx-expression@1.3.2: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@2.1.4: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + ccount: 2.0.1 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-remove-position: 4.0.2 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@2.0.1: + dependencies: + mdast-util-from-markdown: 1.3.1 + mdast-util-mdx-expression: 1.3.2 + mdast-util-mdx-jsx: 2.1.4 + mdast-util-mdxjs-esm: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@1.3.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-from-markdown: 1.3.1 + mdast-util-to-markdown: 1.5.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@3.0.1: + dependencies: + '@types/mdast': 3.0.15 + unist-util-is: 5.2.1 + + mdast-util-to-hast@12.3.0: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-definitions: 5.1.2 + micromark-util-sanitize-uri: 1.2.0 + trim-lines: 3.0.1 + unist-util-generated: 2.0.1 + unist-util-position: 4.0.4 + unist-util-visit: 4.1.2 + + mdast-util-to-markdown@1.5.0: + dependencies: + '@types/mdast': 3.0.15 + '@types/unist': 2.0.11 + longest-streak: 3.1.0 + mdast-util-phrasing: 3.0.1 + mdast-util-to-string: 3.2.0 + micromark-util-decode-string: 1.1.0 + unist-util-visit: 4.1.2 + zwitch: 2.0.4 + + mdast-util-to-string@3.2.0: + dependencies: + '@types/mdast': 3.0.15 + + mdn-data@2.0.14: {} + + media-query-parser@2.0.2: + dependencies: + '@babel/runtime': 7.28.6 + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + methods@1.1.2: {} + + micromark-core-commonmark@1.1.0: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-factory-destination: 1.1.0 + micromark-factory-label: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-factory-title: 1.1.0 + micromark-factory-whitespace: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-chunked: 1.1.0 + micromark-util-classify-character: 1.1.0 + micromark-util-html-tag-name: 1.2.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-subtokenize: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-frontmatter@1.1.1: + dependencies: + fault: 2.0.1 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-extension-mdx-expression@1.0.8: + dependencies: + '@types/estree': 1.0.8 + micromark-factory-mdx-expression: 1.0.9 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-extension-mdx-jsx@1.0.5: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.8 + estree-util-is-identifier-name: 2.1.0 + micromark-factory-mdx-expression: 1.0.9 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + vfile-message: 3.1.4 + + micromark-extension-mdx-md@1.0.1: + dependencies: + micromark-util-types: 1.1.0 + + micromark-extension-mdxjs-esm@1.0.5: + dependencies: + '@types/estree': 1.0.8 + micromark-core-commonmark: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-position-from-estree: 1.1.2 + uvu: 0.5.6 + vfile-message: 3.1.4 + + micromark-extension-mdxjs@1.0.1: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + micromark-extension-mdx-expression: 1.0.8 + micromark-extension-mdx-jsx: 1.0.5 + micromark-extension-mdx-md: 1.0.1 + micromark-extension-mdxjs-esm: 1.0.5 + micromark-util-combine-extensions: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-destination@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-label@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-factory-mdx-expression@1.0.9: + dependencies: + '@types/estree': 1.0.8 + micromark-util-character: 1.2.0 + micromark-util-events-to-acorn: 1.2.3 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + unist-util-position-from-estree: 1.1.2 + uvu: 0.5.6 + vfile-message: 3.1.4 + + micromark-factory-space@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-types: 1.1.0 + + micromark-factory-title@1.1.0: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-factory-whitespace@1.1.0: + dependencies: + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-character@1.2.0: + dependencies: + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-chunked@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-classify-character@1.1.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-combine-extensions@1.1.0: + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-types: 1.1.0 + + micromark-util-decode-numeric-character-reference@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-decode-string@1.1.0: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 1.2.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-symbol: 1.1.0 + + micromark-util-encode@1.1.0: {} + + micromark-util-events-to-acorn@1.2.3: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.8 + '@types/unist': 2.0.11 + estree-util-visit: 1.2.1 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + vfile-message: 3.1.4 + + micromark-util-html-tag-name@1.2.0: {} + + micromark-util-normalize-identifier@1.1.0: + dependencies: + micromark-util-symbol: 1.1.0 + + micromark-util-resolve-all@1.1.0: + dependencies: + micromark-util-types: 1.1.0 + + micromark-util-sanitize-uri@1.2.0: + dependencies: + micromark-util-character: 1.2.0 + micromark-util-encode: 1.1.0 + micromark-util-symbol: 1.1.0 + + micromark-util-subtokenize@1.1.0: + dependencies: + micromark-util-chunked: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + + micromark-util-symbol@1.1.0: {} + + micromark-util-types@1.1.0: {} + + micromark@3.2.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + micromark-core-commonmark: 1.1.0 + micromark-factory-space: 1.1.0 + micromark-util-character: 1.2.0 + micromark-util-chunked: 1.1.0 + micromark-util-combine-extensions: 1.1.0 + micromark-util-decode-numeric-character-reference: 1.1.0 + micromark-util-encode: 1.1.0 + micromark-util-normalize-identifier: 1.1.0 + micromark-util-resolve-all: 1.1.0 + micromark-util-sanitize-uri: 1.2.0 + micromark-util-subtokenize: 1.1.0 + micromark-util-symbol: 1.1.0 + micromark-util-types: 1.1.0 + uvu: 0.5.6 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + mimic-fn@2.1.0: {} + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.3: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + + minipass-flush@1.0.5: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp-classic@0.5.3: {} + + mkdirp@1.0.4: {} + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + modern-ahocorasick@1.1.0: {} + + monaco-editor@0.52.2: {} + + morgan@1.10.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 2.0.0 + on-finished: 2.3.0 + on-headers: 1.1.0 + transitivePeerDependencies: + - supports-color + + mri@1.2.0: {} + + mrmime@1.0.1: {} + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.3: {} + + negotiator@0.6.4: {} + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.27: {} + + normalize-package-data@5.0.0: + dependencies: + hosted-git-info: 6.1.3 + is-core-module: 2.16.1 + semver: 7.7.4 + validate-npm-package-license: 3.0.4 + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + npm-install-checks@6.3.0: + dependencies: + semver: 7.7.4 + + npm-normalize-package-bin@3.0.1: {} + + npm-package-arg@10.1.0: + dependencies: + hosted-git-info: 6.1.3 + proc-log: 3.0.0 + semver: 7.7.4 + validate-npm-package-name: 5.0.1 + + npm-pick-manifest@8.0.2: + dependencies: + npm-install-checks: 6.3.0 + npm-normalize-package-bin: 3.0.1 + npm-package-arg: 10.1.0 + semver: 7.7.4 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.1.0: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + outdent@0.8.0: {} + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-finally@1.0.0: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + package-json-from-dist@1.0.1: {} + + pako@0.2.9: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-ms@2.1.0: {} + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-to-regexp@0.1.12: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + peek-stream@1.1.3: + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.8 + estree-walker: 3.0.3 + is-reference: 3.0.3 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pidtree@0.6.0: {} + + pify@5.0.0: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pkg-types@2.3.0: + dependencies: + confbox: 0.2.4 + exsolve: 1.0.8 + pathe: 2.0.3 + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + + possible-typed-array-names@1.1.0: {} + + postcss-calc@8.2.4(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + postcss-value-parser: 4.2.0 + + postcss-colormin@5.3.1(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + colord: 2.9.3 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-convert-values@5.1.3(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-discard-comments@5.1.2(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-duplicates@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-empty@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-discard-overridden@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-load-config@3.1.4(postcss@8.5.6): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.6 + + postcss-load-config@4.0.2(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + yaml: 2.8.2 + optionalDependencies: + postcss: 8.5.6 + + postcss-merge-longhand@5.1.7(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + stylehacks: 5.1.1(postcss@8.5.6) + + postcss-merge-rules@5.1.4(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-minify-font-values@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-gradients@5.1.1(postcss@8.5.6): + dependencies: + colord: 2.9.3 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-params@5.1.4(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-minify-selectors@5.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + + postcss-modules-values@4.0.0(postcss@8.5.6): + dependencies: + icss-utils: 5.1.0(postcss@8.5.6) + postcss: 8.5.6 + + postcss-modules@4.3.1(postcss@8.5.6): + dependencies: + generic-names: 4.0.0 + icss-replace-symbols: 1.1.0 + lodash.camelcase: 4.3.0 + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + string-hash: 1.1.3 + + postcss-modules@6.0.1(postcss@8.5.6): + dependencies: + generic-names: 4.0.0 + icss-utils: 5.1.0(postcss@8.5.6) + lodash.camelcase: 4.3.0 + postcss: 8.5.6 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.6) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.6) + postcss-modules-scope: 3.2.1(postcss@8.5.6) + postcss-modules-values: 4.0.0(postcss@8.5.6) + string-hash: 1.1.3 + + postcss-normalize-charset@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-normalize-display-values@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-positions@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-repeat-style@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-string@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-timing-functions@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-unicode@5.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-url@5.1.0(postcss@8.5.6): + dependencies: + normalize-url: 6.1.0 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-normalize-whitespace@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-ordered-values@5.1.3(postcss@8.5.6): + dependencies: + cssnano-utils: 3.1.0(postcss@8.5.6) + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-reduce-initial@5.1.2(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-api: 3.0.0 + postcss: 8.5.6 + + postcss-reduce-transforms@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-svgo@5.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + svgo: 2.8.0 + + postcss-unique-selectors@5.1.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@2.8.8: {} + + pretty-ms@7.0.1: + dependencies: + parse-ms: 2.1.0 + + proc-log@3.0.0: {} + + process-nextick-args@2.0.1: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + promise.series@0.2.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + property-information@6.5.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pump@2.0.1: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + pumpify@1.5.1: + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + + punycode@2.3.1: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react-refresh@0.14.2: {} + + react-remove-scroll-bar@2.3.8(@types/react@18.3.28)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react-remove-scroll@2.7.2(@types/react@18.3.28)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.28)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@18.3.28)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.28)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@18.3.28)(react@19.2.4) + optionalDependencies: + '@types/react': 18.3.28 + + react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-router: 6.30.3(react@18.3.1) + + react-router@6.30.3(react@18.3.1): + dependencies: + '@remix-run/router': 1.23.2 + react: 18.3.1 + + react-style-singleton@2.2.3(@types/react@18.3.28)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + react@19.2.4: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + remark-frontmatter@4.0.1: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-frontmatter: 1.0.1 + micromark-extension-frontmatter: 1.1.1 + unified: 10.1.2 + + remark-mdx-frontmatter@1.1.1: + dependencies: + estree-util-is-identifier-name: 1.1.0 + estree-util-value-to-estree: 1.3.0 + js-yaml: 4.1.1 + toml: 3.0.0 + + remark-mdx@2.3.0: + dependencies: + mdast-util-mdx: 2.0.1 + micromark-extension-mdxjs: 1.0.1 + transitivePeerDependencies: + - supports-color + + remark-parse@10.0.2: + dependencies: + '@types/mdast': 3.0.15 + mdast-util-from-markdown: 1.3.1 + unified: 10.1.2 + transitivePeerDependencies: + - supports-color + + remark-rehype@10.1.0: + dependencies: + '@types/hast': 2.3.10 + '@types/mdast': 3.0.15 + mdast-util-to-hast: 12.3.0 + unified: 10.1.2 + + require-like@0.1.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.3: {} + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + rollup-plugin-peer-deps-external@2.2.4(rollup@4.59.0): + dependencies: + rollup: 4.59.0 + + rollup-plugin-postcss@4.0.2(postcss@8.5.6): + dependencies: + chalk: 4.1.2 + concat-with-sourcemaps: 1.1.0 + cssnano: 5.1.15(postcss@8.5.6) + import-cwd: 3.0.0 + p-queue: 6.6.2 + pify: 5.0.0 + postcss: 8.5.6 + postcss-load-config: 3.1.4(postcss@8.5.6) + postcss-modules: 4.3.1(postcss@8.5.6) + promise.series: 0.2.0 + resolve: 1.22.11 + rollup-pluginutils: 2.8.2 + safe-identifier: 0.4.2 + style-inject: 0.3.0 + transitivePeerDependencies: + - ts-node + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-array-concat@1.1.3: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safe-identifier@0.4.2: {} + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safer-buffer@2.1.2: {} + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + spdx-correct@3.2.0: + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.23 + + spdx-exceptions@2.5.0: {} + + spdx-expression-parse@3.0.1: + dependencies: + spdx-exceptions: 2.5.0 + spdx-license-ids: 3.0.23 + + spdx-license-ids@3.0.23: {} + + ssri@10.0.6: + dependencies: + minipass: 7.1.3 + + stable@0.1.8: {} + + state-local@1.0.7: {} + + statuses@2.0.2: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + stream-shift@1.0.3: {} + + stream-slice@0.1.2: {} + + string-hash@1.1.3: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.1 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.1 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.8 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + style-inject@0.3.0: {} + + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + + stylehacks@5.1.1(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + svgo@2.8.0: + dependencies: + '@trysound/sax': 0.2.0 + commander: 7.2.0 + css-select: 4.3.0 + css-tree: 1.1.3 + csso: 4.2.0 + picocolors: 1.1.1 + stable: 0.1.8 + + tailwind-merge@3.5.0: {} + + tailwindcss-animate@1.0.7(tailwindcss@4.2.1): + dependencies: + tailwindcss: 4.2.1 + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + text-table@0.2.0: {} + + through2@2.0.5: + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + toml@3.0.0: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.4.3(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + turbo-stream@2.4.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.8 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript@5.9.3: {} + + ufo@1.6.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@7.18.2: {} + + undici@6.23.0: {} + + unified@10.1.2: + dependencies: + '@types/unist': 2.0.11 + bail: 2.0.2 + extend: 3.0.2 + is-buffer: 2.0.5 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 5.3.7 + + unique-filename@3.0.0: + dependencies: + unique-slug: 4.0.0 + + unique-slug@4.0.0: + dependencies: + imurmurhash: 0.1.4 + + unist-util-generated@2.0.1: {} + + unist-util-is@5.2.1: + dependencies: + '@types/unist': 2.0.11 + + unist-util-position-from-estree@1.1.2: + dependencies: + '@types/unist': 2.0.11 + + unist-util-position@4.0.4: + dependencies: + '@types/unist': 2.0.11 + + unist-util-remove-position@4.0.2: + dependencies: + '@types/unist': 2.0.11 + unist-util-visit: 4.1.2 + + unist-util-stringify-position@3.0.3: + dependencies: + '@types/unist': 2.0.11 + + unist-util-visit-parents@5.1.3: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + + unist-util-visit@4.1.2: + dependencies: + '@types/unist': 2.0.11 + unist-util-is: 5.2.1 + unist-util-visit-parents: 5.1.3 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@18.3.28)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + use-sidecar@1.1.3(@types/react@18.3.28)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 18.3.28 + + util-deprecate@1.0.2: {} + + util@0.12.5: + dependencies: + inherits: 2.0.4 + is-arguments: 1.2.0 + is-generator-function: 1.1.2 + is-typed-array: 1.1.15 + which-typed-array: 1.1.20 + + utils-merge@1.0.1: {} + + uvu@0.5.6: + dependencies: + dequal: 2.0.3 + diff: 5.2.2 + kleur: 4.1.5 + sade: 1.8.1 + + valibot@1.2.0(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + validate-npm-package-license@3.0.4: + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vfile-message@3.1.4: + dependencies: + '@types/unist': 2.0.11 + unist-util-stringify-position: 3.0.3 + + vfile@5.3.7: + dependencies: + '@types/unist': 2.0.11 + is-buffer: 2.0.5 + unist-util-stringify-position: 3.0.3 + vfile-message: 3.1.4 + + vite-node@1.6.1(@types/node@25.3.2)(lightningcss@1.31.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite-node@3.2.4(@types/node@25.3.2)(lightningcss@1.31.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 5.4.21(@types/node@25.3.2)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21(@types/node@25.3.2)(lightningcss@1.31.1): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.59.0 + optionalDependencies: + '@types/node': 25.3.2 + fsevents: 2.3.3 + lightningcss: 1.31.1 + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + web-encoding@1.1.5: + dependencies: + util: 0.12.5 + optionalDependencies: + '@zxing/text-encoding': 0.9.0 + + web-streams-polyfill@3.3.3: {} + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.8 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@3.0.1: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@7.5.10: {} + + xtend@4.0.2: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yaml@1.10.2: {} + + yaml@2.8.2: {} + + yocto-queue@0.1.0: {} + + zwitch@2.0.4: {} diff --git a/ui/postcss.config.js b/ui/postcss.config.js index 51a6e4e6..9df712d7 100644 --- a/ui/postcss.config.js +++ b/ui/postcss.config.js @@ -1,6 +1,6 @@ -export default { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -}; +export default { + plugins: { + '@tailwindcss/postcss': {}, + autoprefixer: {}, + }, +}; diff --git a/ui/rollup.config.mjs b/ui/rollup.config.mjs index 071bb9b7..e01a243f 100644 --- a/ui/rollup.config.mjs +++ b/ui/rollup.config.mjs @@ -1,57 +1,57 @@ -import resolve from '@rollup/plugin-node-resolve'; -import commonjs from '@rollup/plugin-commonjs'; -import typescript from '@rollup/plugin-typescript'; -import peerDepsExternal from 'rollup-plugin-peer-deps-external'; -import postcss from 'rollup-plugin-postcss'; -import autoprefixer from 'autoprefixer'; - -export default { - input: 'src/index.ts', - output: [ - { - file: 'dist/index.js', - format: 'cjs', - sourcemap: true, - exports: 'named', - }, - { - file: 'dist/index.esm.js', - format: 'esm', - sourcemap: true, - }, - ], - plugins: [ - peerDepsExternal(), - resolve({ - extensions: ['.ts', '.tsx', '.js', '.jsx'], - }), - commonjs({ - include: /node_modules/, - // Don't try to convert React to CommonJS - ignore: ['react', 'react-dom', 'react/jsx-runtime'], - }), - typescript({ - tsconfig: './tsconfig.json', - declaration: true, - declarationDir: 'dist', - noEmitOnError: false, - }), - postcss({ - // Don't extract CSS - host app provides Tailwind - // This avoids CSS layer conflicts with host applications - inject: false, - minimize: true, - plugins: [ - autoprefixer(), - ], - }), - ], - external: [ - 'react', - 'react-dom', - 'react/jsx-runtime', - /^react\//, - /^react-dom\//, - /^@radix-ui\//, - ], -}; +import resolve from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import typescript from '@rollup/plugin-typescript'; +import peerDepsExternal from 'rollup-plugin-peer-deps-external'; +import postcss from 'rollup-plugin-postcss'; +import autoprefixer from 'autoprefixer'; + +export default { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.js', + format: 'cjs', + sourcemap: true, + exports: 'named', + }, + { + file: 'dist/index.esm.js', + format: 'esm', + sourcemap: true, + }, + ], + plugins: [ + peerDepsExternal(), + resolve({ + extensions: ['.ts', '.tsx', '.js', '.jsx'], + }), + commonjs({ + include: /node_modules/, + // Don't try to convert React to CommonJS + ignore: ['react', 'react-dom', 'react/jsx-runtime'], + }), + typescript({ + tsconfig: './tsconfig.json', + declaration: true, + declarationDir: 'dist', + noEmitOnError: false, + }), + postcss({ + // Don't extract CSS - host app provides Tailwind + // This avoids CSS layer conflicts with host applications + inject: false, + minimize: true, + plugins: [ + autoprefixer(), + ], + }), + ], + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + /^react\//, + /^react-dom\//, + /^@radix-ui\//, + ], +}; diff --git a/ui/src/components/ActionMultiSelect.tsx b/ui/src/components/ActionMultiSelect.tsx index fb9a41a2..9e135691 100644 --- a/ui/src/components/ActionMultiSelect.tsx +++ b/ui/src/components/ActionMultiSelect.tsx @@ -1,123 +1,123 @@ -import * as React from 'react'; -import * as Popover from '@radix-ui/react-popover'; -import { ChevronDown } from 'lucide-react'; -import { Checkbox } from './ui/checkbox'; -import { cn } from '../lib/utils'; - -export interface ActionMultiSelectOption { - value: string; - label: string; - count?: number; -} - -export interface ActionMultiSelectProps { - /** Current selected verbs */ - value: string[]; - /** Handler called when selection changes */ - onChange: (verbs: string[]) => void; - /** Additional CSS class */ - className?: string; - /** Whether the select is disabled */ - disabled?: boolean; - /** Available action options with counts */ - options: ActionMultiSelectOption[]; - /** Whether facets are still loading */ - isLoading?: boolean; -} - -/** - * ActionMultiSelect provides a compact multi-select dropdown for filtering by action/verb. - * Uses checkboxes for multiple selection and displays counts from facet queries. - */ -export function ActionMultiSelect({ - value, - onChange, - className = '', - disabled = false, - options, - isLoading = false, -}: ActionMultiSelectProps) { - const [open, setOpen] = React.useState(false); - - const handleToggle = React.useCallback( - (actionValue: string) => { - if (value.includes(actionValue)) { - onChange(value.filter((v) => v !== actionValue)); - } else { - onChange([...value, actionValue]); - } - }, - [value, onChange] - ); - - const displayText = React.useMemo(() => { - if (value.length === 0) return 'Actions'; - if (value.length === 1) return '1 action'; - return `${value.length} actions`; - }, [value.length]); - - return ( - - - - - - -
- {isLoading ? ( -
Loading...
- ) : options.length === 0 ? ( -
No actions found
- ) : ( - options.map((option) => { - const checked = value.includes(option.value); - return ( - - ); - }) - )} -
-
-
-
- ); -} +import * as React from 'react'; +import * as Popover from '@radix-ui/react-popover'; +import { ChevronDown } from 'lucide-react'; +import { Checkbox } from './ui/checkbox'; +import { cn } from '../lib/utils'; + +export interface ActionMultiSelectOption { + value: string; + label: string; + count?: number; +} + +export interface ActionMultiSelectProps { + /** Current selected verbs */ + value: string[]; + /** Handler called when selection changes */ + onChange: (verbs: string[]) => void; + /** Additional CSS class */ + className?: string; + /** Whether the select is disabled */ + disabled?: boolean; + /** Available action options with counts */ + options: ActionMultiSelectOption[]; + /** Whether facets are still loading */ + isLoading?: boolean; +} + +/** + * ActionMultiSelect provides a compact multi-select dropdown for filtering by action/verb. + * Uses checkboxes for multiple selection and displays counts from facet queries. + */ +export function ActionMultiSelect({ + value, + onChange, + className = '', + disabled = false, + options, + isLoading = false, +}: ActionMultiSelectProps) { + const [open, setOpen] = React.useState(false); + + const handleToggle = React.useCallback( + (actionValue: string) => { + if (value.includes(actionValue)) { + onChange(value.filter((v) => v !== actionValue)); + } else { + onChange([...value, actionValue]); + } + }, + [value, onChange] + ); + + const displayText = React.useMemo(() => { + if (value.length === 0) return 'Actions'; + if (value.length === 1) return '1 action'; + return `${value.length} actions`; + }, [value.length]); + + return ( + + + + + + +
+ {isLoading ? ( +
Loading...
+ ) : options.length === 0 ? ( +
No actions found
+ ) : ( + options.map((option) => { + const checked = value.includes(option.value); + return ( + + ); + }) + )} +
+
+
+
+ ); +} diff --git a/ui/src/components/ActionToggle.tsx b/ui/src/components/ActionToggle.tsx index cc1eae1f..e6473212 100644 --- a/ui/src/components/ActionToggle.tsx +++ b/ui/src/components/ActionToggle.tsx @@ -1,95 +1,95 @@ -import { Button } from './ui/button'; -import { cn } from '../lib/utils'; - -export type ActionOption = 'all' | 'create' | 'update' | 'delete' | 'get' | 'list' | 'watch'; - -export interface ActionToggleProps { - /** Current selected value */ - value: ActionOption; - /** Handler called when selection changes */ - onChange: (value: ActionOption) => void; - /** Additional CSS class */ - className?: string; - /** Whether the toggle is disabled */ - disabled?: boolean; -} - -/** - * Options for the action toggle - */ -const OPTIONS: { value: ActionOption; label: string; description: string }[] = [ - { - value: 'all', - label: 'All', - description: 'Show all actions', - }, - { - value: 'create', - label: 'Create', - description: 'Show only create actions', - }, - { - value: 'update', - label: 'Update', - description: 'Show update and patch actions', - }, - { - value: 'delete', - label: 'Delete', - description: 'Show only delete actions', - }, - { - value: 'get', - label: 'Get', - description: 'Show only get actions', - }, - { - value: 'list', - label: 'List', - description: 'Show only list actions', - }, - { - value: 'watch', - label: 'Watch', - description: 'Show only watch actions', - }, -]; - -/** - * ActionToggle provides a segmented control for filtering by action/verb - */ -export function ActionToggle({ - value, - onChange, - className = '', - disabled = false, -}: ActionToggleProps) { - return ( -
- {OPTIONS.map((option, index) => ( - - ))} -
- ); -} +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; + +export type ActionOption = 'all' | 'create' | 'update' | 'delete' | 'get' | 'list' | 'watch'; + +export interface ActionToggleProps { + /** Current selected value */ + value: ActionOption; + /** Handler called when selection changes */ + onChange: (value: ActionOption) => void; + /** Additional CSS class */ + className?: string; + /** Whether the toggle is disabled */ + disabled?: boolean; +} + +/** + * Options for the action toggle + */ +const OPTIONS: { value: ActionOption; label: string; description: string }[] = [ + { + value: 'all', + label: 'All', + description: 'Show all actions', + }, + { + value: 'create', + label: 'Create', + description: 'Show only create actions', + }, + { + value: 'update', + label: 'Update', + description: 'Show update and patch actions', + }, + { + value: 'delete', + label: 'Delete', + description: 'Show only delete actions', + }, + { + value: 'get', + label: 'Get', + description: 'Show only get actions', + }, + { + value: 'list', + label: 'List', + description: 'Show only list actions', + }, + { + value: 'watch', + label: 'Watch', + description: 'Show only watch actions', + }, +]; + +/** + * ActionToggle provides a segmented control for filtering by action/verb + */ +export function ActionToggle({ + value, + onChange, + className = '', + disabled = false, +}: ActionToggleProps) { + return ( +
+ {OPTIONS.map((option, index) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/ActivityExpandedDetails.tsx b/ui/src/components/ActivityExpandedDetails.tsx index 90fc0e8a..cfbdb0bd 100644 --- a/ui/src/components/ActivityExpandedDetails.tsx +++ b/ui/src/components/ActivityExpandedDetails.tsx @@ -1,222 +1,222 @@ -import { useState } from 'react'; -import { format } from 'date-fns'; -import { Copy, Check } from 'lucide-react'; -import type { Activity, TenantLinkResolver } from '../types/activity'; -import { TenantBadge } from './TenantBadge'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './ui/tooltip'; - -export interface ActivityExpandedDetailsProps { - /** The activity to display details for */ - activity: Activity; - /** Optional resolver function to make tenant badges clickable */ - tenantLinkResolver?: TenantLinkResolver; -} - -/** - * Format timestamp for display (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * CopyButton component for copying field values to clipboard - */ -function CopyButton({ value, label }: { value: string; label: string }) { - const [isCopied, setIsCopied] = useState(false); - - const handleCopy = async (e: React.MouseEvent) => { - e.stopPropagation(); - try { - await navigator.clipboard.writeText(value); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - return ( - - - - - -

{isCopied ? 'Copied!' : `Copy ${label}`}

-
-
- ); -} - -/** - * ActivityExpandedDetails renders the expanded details section for an activity. - * Used by both feed and timeline variants of ActivityFeedItem for consistent UX. - * - * Section order (most to least relevant for investigation): - * 1. Changes - what changed (most actionable) - * 2. Timestamp - when it happened - * 3. Tenant - scope of the activity - * 4. Actor - who made the change - * 5. Resource - what resource was affected - * 6. Origin - correlation to audit logs - */ -export function ActivityExpandedDetails({ activity, tenantLinkResolver }: ActivityExpandedDetailsProps) { - const { spec, metadata } = activity; - const { actor, resource, origin, changes, tenant } = spec; - const timestamp = metadata?.creationTimestamp; - - return ( - -
- {/* Field Changes - Most actionable, shown first */} - {changes && changes.length > 0 && ( -
-

- Changes -

-
- {changes.map((change, index) => ( -
- - {change.field} - - {change.old && ( - - - {change.old} - - )} - {change.new && ( - - + - {change.new} - - )} -
- ))} -
-
- )} - - {/* CSS Grid layout with reduced min-width for more columns */} -
- {/* 1. Timestamp */} -
-
Timestamp:
-
- {formatTimestampFull(timestamp)} - -
-
- - {/* 2. Actor Type */} -
-
Actor Type:
-
- {actor.type} - -
-
- - {/* 3. Actor */} -
-
Actor:
-
- {actor.name} - -
-
- - {/* 4. API Group */} - {resource.apiGroup && ( -
-
API Group:
-
- {resource.apiGroup} - -
-
- )} - - {/* 5. Resource */} -
-
Resource:
-
- {resource.kind} - -
-
- - {/* 6. Resource Name */} -
-
Resource Name:
-
- {resource.name} - -
-
- - {/* 7. Namespace */} - {resource.namespace && ( -
-
Namespace:
-
- {resource.namespace} - -
-
- )} - - {/* 8. Resource UID */} - {resource.uid && ( -
-
Resource UID:
-
- {resource.uid} - -
-
- )} - - {/* 9. Origin */} -
-
Origin:
-
- {origin.type} - -
-
- - {/* 10. Origin ID */} -
-
Origin ID:
-
- {origin.id} - -
-
-
-
-
- ); -} +import { useState } from 'react'; +import { format } from 'date-fns'; +import { Copy, Check } from 'lucide-react'; +import type { Activity, TenantLinkResolver } from '../types/activity'; +import { TenantBadge } from './TenantBadge'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; + +export interface ActivityExpandedDetailsProps { + /** The activity to display details for */ + activity: Activity; + /** Optional resolver function to make tenant badges clickable */ + tenantLinkResolver?: TenantLinkResolver; +} + +/** + * Format timestamp for display (with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + } catch { + return timestamp; + } +} + +/** + * CopyButton component for copying field values to clipboard + */ +function CopyButton({ value, label }: { value: string; label: string }) { + const [isCopied, setIsCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { + await navigator.clipboard.writeText(value); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + + + + +

{isCopied ? 'Copied!' : `Copy ${label}`}

+
+
+ ); +} + +/** + * ActivityExpandedDetails renders the expanded details section for an activity. + * Used by both feed and timeline variants of ActivityFeedItem for consistent UX. + * + * Section order (most to least relevant for investigation): + * 1. Changes - what changed (most actionable) + * 2. Timestamp - when it happened + * 3. Tenant - scope of the activity + * 4. Actor - who made the change + * 5. Resource - what resource was affected + * 6. Origin - correlation to audit logs + */ +export function ActivityExpandedDetails({ activity, tenantLinkResolver }: ActivityExpandedDetailsProps) { + const { spec, metadata } = activity; + const { actor, resource, origin, changes, tenant } = spec; + const timestamp = metadata?.creationTimestamp; + + return ( + +
+ {/* Field Changes - Most actionable, shown first */} + {changes && changes.length > 0 && ( +
+

+ Changes +

+
+ {changes.map((change, index) => ( +
+ + {change.field} + + {change.old && ( + + + {change.old} + + )} + {change.new && ( + + + + {change.new} + + )} +
+ ))} +
+
+ )} + + {/* CSS Grid layout with reduced min-width for more columns */} +
+ {/* 1. Timestamp */} +
+
Timestamp:
+
+ {formatTimestampFull(timestamp)} + +
+
+ + {/* 2. Actor Type */} +
+
Actor Type:
+
+ {actor.type} + +
+
+ + {/* 3. Actor */} +
+
Actor:
+
+ {actor.name} + +
+
+ + {/* 4. API Group */} + {resource.apiGroup && ( +
+
API Group:
+
+ {resource.apiGroup} + +
+
+ )} + + {/* 5. Resource */} +
+
Resource:
+
+ {resource.kind} + +
+
+ + {/* 6. Resource Name */} +
+
Resource Name:
+
+ {resource.name} + +
+
+ + {/* 7. Namespace */} + {resource.namespace && ( +
+
Namespace:
+
+ {resource.namespace} + +
+
+ )} + + {/* 8. Resource UID */} + {resource.uid && ( +
+
Resource UID:
+
+ {resource.uid} + +
+
+ )} + + {/* 9. Origin */} +
+
Origin:
+
+ {origin.type} + +
+
+ + {/* 10. Origin ID */} +
+
Origin ID:
+
+ {origin.id} + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 01bf649e..c381c918 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -1,425 +1,425 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; -import type { Activity, ResourceRef, ResourceLinkResolver, TenantLinkResolver, TenantRenderer, EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; -import type { - ActivityFeedFilters as FilterState, - TimeRange, -} from '../hooks/useActivityFeed'; -import { useActivityFeed } from '../hooks/useActivityFeed'; -import { ActivityFeedItem } from './ActivityFeedItem'; -import { ActivityFeedItemSkeleton } from './ActivityFeedItemSkeleton'; -import { ActivityFeedFilters } from './ActivityFeedFilters'; -import { ActivityApiClient } from '../api/client'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; -import { ApiErrorAlert } from './ApiErrorAlert'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; - -export interface ActivityFeedProps { - /** API client instance */ - client: ActivityApiClient; - /** Initial filter settings */ - initialFilters?: FilterState; - /** Initial time range */ - initialTimeRange?: TimeRange; - /** Number of items per page */ - pageSize?: number; - /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ - onResourceClick?: (resource: ResourceRef) => void; - /** Function that resolves resource references to URLs */ - resourceLinkResolver?: ResourceLinkResolver; - /** Function that resolves tenant references to URLs */ - tenantLinkResolver?: TenantLinkResolver; - /** Custom renderer for tenant badges (overrides default TenantBadge) */ - tenantRenderer?: TenantRenderer; - /** Handler called when an activity is clicked */ - onActivityClick?: (activity: Activity) => void; - /** Whether to show in compact mode (for resource detail tabs) */ - compact?: boolean; - /** Filter to a specific resource UID */ - resourceUid?: string; - /** Whether to show filters */ - showFilters?: boolean; - /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'changeSource'>; - /** Additional CSS class */ - className?: string; - /** Enable infinite scroll (default: true) */ - infiniteScroll?: boolean; - /** Threshold in pixels for triggering load more (default: 200) */ - loadMoreThreshold?: number; - /** Handler called when user wants to create a policy (for empty state) */ - onCreatePolicy?: () => void; - /** Enable real-time streaming (default: false) */ - enableStreaming?: boolean; - /** Callback invoked when the effective time range is resolved */ - onEffectiveTimeRangeChange?: EffectiveTimeRangeCallback; - /** Custom error formatter for customizing error messages */ - errorFormatter?: ErrorFormatter; - /** - * Maximum height for the scroll container (CSS value like '500px' or 'calc(100vh - 300px)'). - * By default, the component uses flex layout (flex-1 min-h-0) which adapts to parent container constraints. - * Only set this if your parent container doesn't have proper height constraints. - * Set to 'none' to explicitly disable any max-height constraint. - */ - maxHeight?: string; - /** Callback invoked when filters or time range change (useful for URL state management) */ - onFiltersChange?: (filters: FilterState, timeRange: TimeRange) => void; -} - -/** - * ActivityFeed displays a chronological list of activities with filtering and pagination. - * Supports optional real-time streaming of new activities. - */ -export function ActivityFeed({ - client, - initialFilters = { changeSource: 'human' }, - initialTimeRange = { start: 'now-7d' }, - pageSize = 30, - onResourceClick, - resourceLinkResolver, - tenantLinkResolver, - tenantRenderer, - onActivityClick, - compact = false, - resourceUid, - showFilters = true, - hiddenFilters = [], - className = '', - infiniteScroll = true, - loadMoreThreshold = 200, - onCreatePolicy, - enableStreaming = false, - onEffectiveTimeRangeChange, - errorFormatter, - maxHeight, - onFiltersChange: onFiltersChangeProp, -}: ActivityFeedProps) { - // Merge resourceUid into initial filters if provided - const mergedInitialFilters: FilterState = { - ...initialFilters, - resourceUid: resourceUid || initialFilters.resourceUid, - }; - - const { - activities, - isLoading, - error, - watchError, - hasMore, - filters, - timeRange, - refresh, - loadMore, - setFilters, - setTimeRange, - isStreaming, - startStreaming, - stopStreaming, - newActivitiesCount, - } = useActivityFeed({ - client, - initialFilters: mergedInitialFilters, - initialTimeRange, - pageSize, - enableStreaming, - autoStartStreaming: true, - onEffectiveTimeRangeChange, - }); - - const scrollContainerRef = useRef(null); - const loadMoreTriggerRef = useRef(null); - // Store the latest loadMore function in a ref to avoid observer re-subscription - const loadMoreRef = useRef(loadMore); - - // Track whether policies exist in the system - const [hasPolicies, setHasPolicies] = useState(null); - const [policiesLoading, setPoliciesLoading] = useState(true); - - // Check for policies on mount - useEffect(() => { - const checkPolicies = async () => { - try { - const policyList = await client.listPolicies(); - setHasPolicies((policyList.items?.length ?? 0) > 0); - } catch { - // If we can't check policies, assume they might exist - setHasPolicies(true); - } finally { - setPoliciesLoading(false); - } - }; - checkPolicies(); - }, [client]); - - // Auto-execute on mount - useEffect(() => { - refresh(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Update the ref whenever loadMore changes - useEffect(() => { - loadMoreRef.current = loadMore; - }, [loadMore]); - - // Infinite scroll using Intersection Observer - useEffect(() => { - if (!infiniteScroll || !loadMoreTriggerRef.current) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { - // Call through the ref to always use the latest function - loadMoreRef.current(); - } - }, - { - root: scrollContainerRef.current, - rootMargin: `${loadMoreThreshold}px`, - threshold: 0, - } - ); - - observer.observe(loadMoreTriggerRef.current); - - return () => { - observer.disconnect(); - }; - }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); - - // Handle filter changes - refresh is automatic via the hook - const handleFiltersChange = useCallback( - (newFilters: FilterState) => { - setFilters(newFilters); - onFiltersChangeProp?.(newFilters, timeRange); - }, - [setFilters, onFiltersChangeProp, timeRange] - ); - - // Handle time range changes - refresh is automatic via the hook - const handleTimeRangeChange = useCallback( - (newTimeRange: TimeRange) => { - setTimeRange(newTimeRange); - onFiltersChangeProp?.(filters, newTimeRange); - }, - [setTimeRange, onFiltersChangeProp, filters] - ); - - // Handle manual load more click - const handleLoadMoreClick = useCallback(() => { - loadMore(); - }, [loadMore]); - - // Handle streaming toggle - const handleStreamingToggle = useCallback(() => { - if (isStreaming) { - stopStreaming(); - } else { - startStreaming(); - } - }, [isStreaming, startStreaming, stopStreaming]); - - // Handle actor click - filter by actor name - const handleActorClick = useCallback((actorName: string) => { - setFilters({ - ...filters, - actorNames: [actorName], - }); - }, [filters, setFilters]); - - // Build container classes - use flex layout to properly fill available space - // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling - const containerClasses = compact - ? `flex-1 min-h-0 flex flex-col p-1 shadow-none border-border ${className}` - : `flex-1 min-h-0 flex flex-col p-3 ${className}`; - - // Build list classes - use flex-1 min-h-0 for flex-based scrolling - // Parent containers must have proper height constraints (h-screen/h-full + overflow-hidden) - const effectiveMaxHeight = maxHeight === 'none' ? undefined : maxHeight; - const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2 flex flex-col'; - - return ( - - {/* Header with streaming status */} - {enableStreaming && ( -
-
- {isStreaming && !watchError && ( - - - -
- - - - - Streaming activity... -
-
- -

New activities will appear automatically

-
-
-
- )} - {watchError && ( - - - -
- - - - Connection error -
-
- -

Stream connection lost

-
-
-
- )} - {newActivitiesCount > 0 && !watchError && ( - - +{newActivitiesCount} new - - )} -
- -
- )} - - {/* Filters */} - {showFilters && ( - - )} - - {/* Query Error Display */} - - - {/* Watch Stream Error Display */} - - - {/* No Policies Empty State */} - {!policiesLoading && hasPolicies === false && ( -
-
- - - - - - -
-

Get started with activity logging

-

- Activity policies define which resources to track and how to summarize changes. - Create your first policy to start seeing activity logs here. -

- {onCreatePolicy && ( - - )} -
- )} - - {/* Activity List */} -
- {/* Skeleton Loading State - show when loading and no items yet */} - {isLoading && activities.length === 0 && ( - <> - {Array.from({ length: 8 }).map((_, index) => ( - - ))} - - )} - - {/* Empty State - only show when not loading */} - {!isLoading && activities.length === 0 && hasPolicies !== false && ( -
-

No activities found

-

- Try adjusting your filters or time range -

-
- )} - - {activities.map((activity, index) => ( - - ))} - - {/* Load More Trigger for Infinite Scroll */} - {infiniteScroll && hasMore && ( -
- )} - - {/* Load More Button (when infinite scroll is disabled) */} - {!infiniteScroll && hasMore && !isLoading && ( -
- -
- )} - - {/* End of Results */} - {!hasMore && activities.length > 0 && !isLoading && ( -
- No more activities to load -
- )} -
- - ); -} +import { useEffect, useRef, useCallback, useState } from 'react'; +import type { Activity, ResourceRef, ResourceLinkResolver, TenantLinkResolver, TenantRenderer, EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; +import type { + ActivityFeedFilters as FilterState, + TimeRange, +} from '../hooks/useActivityFeed'; +import { useActivityFeed } from '../hooks/useActivityFeed'; +import { ActivityFeedItem } from './ActivityFeedItem'; +import { ActivityFeedItemSkeleton } from './ActivityFeedItemSkeleton'; +import { ActivityFeedFilters } from './ActivityFeedFilters'; +import { ActivityApiClient } from '../api/client'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Badge } from './ui/badge'; +import { ApiErrorAlert } from './ApiErrorAlert'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; + +export interface ActivityFeedProps { + /** API client instance */ + client: ActivityApiClient; + /** Initial filter settings */ + initialFilters?: FilterState; + /** Initial time range */ + initialTimeRange?: TimeRange; + /** Number of items per page */ + pageSize?: number; + /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ + onResourceClick?: (resource: ResourceRef) => void; + /** Function that resolves resource references to URLs */ + resourceLinkResolver?: ResourceLinkResolver; + /** Function that resolves tenant references to URLs */ + tenantLinkResolver?: TenantLinkResolver; + /** Custom renderer for tenant badges (overrides default TenantBadge) */ + tenantRenderer?: TenantRenderer; + /** Handler called when an activity is clicked */ + onActivityClick?: (activity: Activity) => void; + /** Whether to show in compact mode (for resource detail tabs) */ + compact?: boolean; + /** Filter to a specific resource UID */ + resourceUid?: string; + /** Whether to show filters */ + showFilters?: boolean; + /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ + hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'changeSource'>; + /** Additional CSS class */ + className?: string; + /** Enable infinite scroll (default: true) */ + infiniteScroll?: boolean; + /** Threshold in pixels for triggering load more (default: 200) */ + loadMoreThreshold?: number; + /** Handler called when user wants to create a policy (for empty state) */ + onCreatePolicy?: () => void; + /** Enable real-time streaming (default: false) */ + enableStreaming?: boolean; + /** Callback invoked when the effective time range is resolved */ + onEffectiveTimeRangeChange?: EffectiveTimeRangeCallback; + /** Custom error formatter for customizing error messages */ + errorFormatter?: ErrorFormatter; + /** + * Maximum height for the scroll container (CSS value like '500px' or 'calc(100vh - 300px)'). + * By default, the component uses flex layout (flex-1 min-h-0) which adapts to parent container constraints. + * Only set this if your parent container doesn't have proper height constraints. + * Set to 'none' to explicitly disable any max-height constraint. + */ + maxHeight?: string; + /** Callback invoked when filters or time range change (useful for URL state management) */ + onFiltersChange?: (filters: FilterState, timeRange: TimeRange) => void; +} + +/** + * ActivityFeed displays a chronological list of activities with filtering and pagination. + * Supports optional real-time streaming of new activities. + */ +export function ActivityFeed({ + client, + initialFilters = { changeSource: 'human' }, + initialTimeRange = { start: 'now-7d' }, + pageSize = 30, + onResourceClick, + resourceLinkResolver, + tenantLinkResolver, + tenantRenderer, + onActivityClick, + compact = false, + resourceUid, + showFilters = true, + hiddenFilters = [], + className = '', + infiniteScroll = true, + loadMoreThreshold = 200, + onCreatePolicy, + enableStreaming = false, + onEffectiveTimeRangeChange, + errorFormatter, + maxHeight, + onFiltersChange: onFiltersChangeProp, +}: ActivityFeedProps) { + // Merge resourceUid into initial filters if provided + const mergedInitialFilters: FilterState = { + ...initialFilters, + resourceUid: resourceUid || initialFilters.resourceUid, + }; + + const { + activities, + isLoading, + error, + watchError, + hasMore, + filters, + timeRange, + refresh, + loadMore, + setFilters, + setTimeRange, + isStreaming, + startStreaming, + stopStreaming, + newActivitiesCount, + } = useActivityFeed({ + client, + initialFilters: mergedInitialFilters, + initialTimeRange, + pageSize, + enableStreaming, + autoStartStreaming: true, + onEffectiveTimeRangeChange, + }); + + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + // Store the latest loadMore function in a ref to avoid observer re-subscription + const loadMoreRef = useRef(loadMore); + + // Track whether policies exist in the system + const [hasPolicies, setHasPolicies] = useState(null); + const [policiesLoading, setPoliciesLoading] = useState(true); + + // Check for policies on mount + useEffect(() => { + const checkPolicies = async () => { + try { + const policyList = await client.listPolicies(); + setHasPolicies((policyList.items?.length ?? 0) > 0); + } catch { + // If we can't check policies, assume they might exist + setHasPolicies(true); + } finally { + setPoliciesLoading(false); + } + }; + checkPolicies(); + }, [client]); + + // Auto-execute on mount + useEffect(() => { + refresh(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update the ref whenever loadMore changes + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + // Infinite scroll using Intersection Observer + useEffect(() => { + if (!infiniteScroll || !loadMoreTriggerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && hasMore && !isLoading) { + // Call through the ref to always use the latest function + loadMoreRef.current(); + } + }, + { + root: scrollContainerRef.current, + rootMargin: `${loadMoreThreshold}px`, + threshold: 0, + } + ); + + observer.observe(loadMoreTriggerRef.current); + + return () => { + observer.disconnect(); + }; + }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); + + // Handle filter changes - refresh is automatic via the hook + const handleFiltersChange = useCallback( + (newFilters: FilterState) => { + setFilters(newFilters); + onFiltersChangeProp?.(newFilters, timeRange); + }, + [setFilters, onFiltersChangeProp, timeRange] + ); + + // Handle time range changes - refresh is automatic via the hook + const handleTimeRangeChange = useCallback( + (newTimeRange: TimeRange) => { + setTimeRange(newTimeRange); + onFiltersChangeProp?.(filters, newTimeRange); + }, + [setTimeRange, onFiltersChangeProp, filters] + ); + + // Handle manual load more click + const handleLoadMoreClick = useCallback(() => { + loadMore(); + }, [loadMore]); + + // Handle streaming toggle + const handleStreamingToggle = useCallback(() => { + if (isStreaming) { + stopStreaming(); + } else { + startStreaming(); + } + }, [isStreaming, startStreaming, stopStreaming]); + + // Handle actor click - filter by actor name + const handleActorClick = useCallback((actorName: string) => { + setFilters({ + ...filters, + actorNames: [actorName], + }); + }, [filters, setFilters]); + + // Build container classes - use flex layout to properly fill available space + // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling + const containerClasses = compact + ? `flex-1 min-h-0 flex flex-col p-1 shadow-none border-border ${className}` + : `flex-1 min-h-0 flex flex-col p-3 ${className}`; + + // Build list classes - use flex-1 min-h-0 for flex-based scrolling + // Parent containers must have proper height constraints (h-screen/h-full + overflow-hidden) + const effectiveMaxHeight = maxHeight === 'none' ? undefined : maxHeight; + const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2 flex flex-col'; + + return ( + + {/* Header with streaming status */} + {enableStreaming && ( +
+
+ {isStreaming && !watchError && ( + + + +
+ + + + + Streaming activity... +
+
+ +

New activities will appear automatically

+
+
+
+ )} + {watchError && ( + + + +
+ + + + Connection error +
+
+ +

Stream connection lost

+
+
+
+ )} + {newActivitiesCount > 0 && !watchError && ( + + +{newActivitiesCount} new + + )} +
+ +
+ )} + + {/* Filters */} + {showFilters && ( + + )} + + {/* Query Error Display */} + + + {/* Watch Stream Error Display */} + + + {/* No Policies Empty State */} + {!policiesLoading && hasPolicies === false && ( +
+
+ + + + + + +
+

Get started with activity logging

+

+ Activity policies define which resources to track and how to summarize changes. + Create your first policy to start seeing activity logs here. +

+ {onCreatePolicy && ( + + )} +
+ )} + + {/* Activity List */} +
+ {/* Skeleton Loading State - show when loading and no items yet */} + {isLoading && activities.length === 0 && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + + )} + + {/* Empty State - only show when not loading */} + {!isLoading && activities.length === 0 && hasPolicies !== false && ( +
+

No activities found

+

+ Try adjusting your filters or time range +

+
+ )} + + {activities.map((activity, index) => ( + + ))} + + {/* Load More Trigger for Infinite Scroll */} + {infiniteScroll && hasMore && ( +
+ )} + + {/* Load More Button (when infinite scroll is disabled) */} + {!infiniteScroll && hasMore && !isLoading && ( +
+ +
+ )} + + {/* End of Results */} + {!hasMore && activities.length > 0 && !isLoading && ( +
+ No more activities to load +
+ )} +
+ + ); +} diff --git a/ui/src/components/ActivityFeedFilters.tsx b/ui/src/components/ActivityFeedFilters.tsx index d2348aa5..aaa0d037 100644 --- a/ui/src/components/ActivityFeedFilters.tsx +++ b/ui/src/components/ActivityFeedFilters.tsx @@ -1,417 +1,417 @@ -import { useState, useCallback, useEffect } from 'react'; -import { formatISO, subDays } from 'date-fns'; -import { Search } from 'lucide-react'; - -import type { ActivityFeedFilters as FilterState } from '../hooks/useActivityFeed'; -import type { TimeRange } from '../hooks/useActivityFeed'; -import type { ActivityApiClient } from '../api/client'; -import { useFacets } from '../hooks/useFacets'; -import { ChangeSourceToggle, ChangeSourceOption } from './ChangeSourceToggle'; -import { TimeRangeDropdown } from './ui/time-range-dropdown'; -import { FilterChip } from './ui/filter-chip'; -import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { Input } from './ui/input'; - -export interface ActivityFeedFiltersProps { - /** API client instance for fetching facets */ - client: ActivityApiClient; - /** Current filter state */ - filters: FilterState; - /** Current time range */ - timeRange: TimeRange; - /** Handler called when filters change */ - onFiltersChange: (filters: FilterState) => void; - /** Handler called when time range changes */ - onTimeRangeChange: (timeRange: TimeRange) => void; - /** Whether the filters are disabled (e.g., during loading) */ - disabled?: boolean; - /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions' | 'changeSource'>; - /** Additional CSS class */ - className?: string; -} - -/** - * Preset time ranges - */ -const TIME_PRESETS = [ - { key: 'now-1h', label: 'Last hour' }, - { key: 'now-24h', label: 'Last 24 hours' }, - { key: 'now-7d', label: 'Last 7 days' }, - { key: 'now-30d', label: 'Last 30 days' }, -]; - -/** - * Filter configuration registry - */ -type FilterId = 'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions'; - -interface FilterConfig { - id: FilterId; - label: string; - inputMode: 'typeahead' | 'text'; - placeholder?: string; - searchPlaceholder?: string; -} - -const FILTER_CONFIGS: Record = { - resourceKinds: { - id: 'resourceKinds', - label: 'Kind', - inputMode: 'typeahead', - searchPlaceholder: 'Search kinds...', - }, - actorNames: { - id: 'actorNames', - label: 'Actor', - inputMode: 'typeahead', - searchPlaceholder: 'Search actors...', - }, - apiGroups: { - id: 'apiGroups', - label: 'API Group', - inputMode: 'typeahead', - searchPlaceholder: 'Search API groups...', - }, - resourceNamespaces: { - id: 'resourceNamespaces', - label: 'Namespace', - inputMode: 'typeahead', - searchPlaceholder: 'Search namespaces...', - }, - resourceName: { - id: 'resourceName', - label: 'Resource Name', - inputMode: 'text', - placeholder: 'Enter resource name...', - }, - actions: { - id: 'actions', - label: 'Action', - inputMode: 'typeahead', - searchPlaceholder: 'Search actions...', - }, -}; - -/** - * Helper function to convert ISO string to datetime-local format - */ -const formatDatetimeLocal = (isoString: string): string => { - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -/** - * Check if the current time range matches a preset - */ -const getSelectedPreset = (timeRange: TimeRange): string => { - const preset = TIME_PRESETS.find((p) => timeRange.start === p.key); - return preset ? preset.key : 'custom'; -}; - -/** - * ActivityFeedFilters provides filter controls for the activity feed - */ -export function ActivityFeedFilters({ - client, - filters, - timeRange, - onFiltersChange, - onTimeRangeChange, - disabled = false, - hiddenFilters = [], - className = '', -}: ActivityFeedFiltersProps) { - const { resourceKinds, actorNames, apiGroups, resourceNamespaces, error: facetsError } = useFacets(client, timeRange, filters); - - // Log facets error for debugging - if (facetsError) { - console.error('Failed to load facets:', facetsError); - } - - // Track which filter was just added to auto-open it - const [pendingFilter, setPendingFilter] = useState(null); - - // Custom time range state - const selectedPreset = getSelectedPreset(timeRange); - const [customStart, setCustomStart] = useState(() => { - if (selectedPreset === 'custom') { - return formatDatetimeLocal(timeRange.start); - } - return formatDatetimeLocal(formatISO(subDays(new Date(), 1))); - }); - const [customEnd, setCustomEnd] = useState(() => { - if (selectedPreset === 'custom' && timeRange.end) { - return formatDatetimeLocal(timeRange.end); - } - return formatDatetimeLocal(formatISO(new Date())); - }); - - // Handle change source change - const handleChangeSourceChange = useCallback( - (value: ChangeSourceOption) => { - onFiltersChange({ - ...filters, - changeSource: value, - }); - }, - [filters, onFiltersChange] - ); - - // Handle time range preset selection - const handleTimePresetSelect = useCallback( - (presetKey: string) => { - onTimeRangeChange({ - start: presetKey, - end: undefined, - }); - }, - [onTimeRangeChange] - ); - - // Handle custom time range apply - const handleCustomRangeApply = useCallback( - (start: string, end: string) => { - setCustomStart(start); - setCustomEnd(end); - onTimeRangeChange({ - start: new Date(start).toISOString(), - end: new Date(end).toISOString(), - }); - }, - [onTimeRangeChange] - ); - - // Get display label for time range - const getTimeRangeLabel = () => { - const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); - if (preset) return preset.label; - if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { - const start = new Date(timeRange.start); - const end = new Date(timeRange.end); - return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; - } - return 'Select time range'; - }; - - // Determine which filters are currently active (have values) and not hidden - const filtersWithValues: FilterId[] = []; - if (filters.resourceKinds && filters.resourceKinds.length > 0 && !hiddenFilters.includes('resourceKinds')) filtersWithValues.push('resourceKinds'); - if (filters.actorNames && filters.actorNames.length > 0 && !hiddenFilters.includes('actorNames')) filtersWithValues.push('actorNames'); - if (filters.apiGroups && filters.apiGroups.length > 0 && !hiddenFilters.includes('apiGroups')) filtersWithValues.push('apiGroups'); - if (filters.resourceNamespaces && filters.resourceNamespaces.length > 0 && !hiddenFilters.includes('resourceNamespaces')) filtersWithValues.push('resourceNamespaces'); - if (filters.resourceName && !hiddenFilters.includes('resourceName')) filtersWithValues.push('resourceName'); - if (filters.actions && filters.actions.length > 0) filtersWithValues.push('actions'); - - // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters - const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) - ? [...filtersWithValues, pendingFilter] - : filtersWithValues; - - // Clear pending filter when filter values change (user selected something) - useEffect(() => { - if (pendingFilter && filtersWithValues.includes(pendingFilter)) { - // Filter now has values, clear pending state - setPendingFilter(null); - } - }, [pendingFilter, filtersWithValues]); - - // Build available filters list (exclude hidden filters) - const availableFilters: FilterOption[] = [ - { id: 'resourceKinds', label: 'Kind' }, - { id: 'actorNames', label: 'Actor' }, - { id: 'apiGroups', label: 'API Group' }, - { id: 'resourceNamespaces', label: 'Namespace' }, - { id: 'resourceName', label: 'Resource Name' }, - { id: 'actions', label: 'Action' }, - ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); - - // Handle adding a filter - const handleAddFilter = useCallback((filterId: string) => { - setPendingFilter(filterId as FilterId); - }, []); - - // Handle popover close - clear pending filter if no values were selected - const handlePopoverClose = useCallback( - (filterId: FilterId) => { - if (pendingFilter === filterId) { - const hasValues = (() => { - const value = filters[filterId]; - if (filterId === 'resourceName') return !!value; - return Array.isArray(value) && value.length > 0; - })(); - if (!hasValues) { - setPendingFilter(null); - } - } - }, - [pendingFilter, filters] - ); - - // Handle filter value changes - const handleFilterChange = useCallback( - (filterId: FilterId, values: string[]) => { - onFiltersChange({ - ...filters, - [filterId]: values.length > 0 ? values : undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Handle filter clear - const handleFilterClear = useCallback( - (filterId: FilterId) => { - onFiltersChange({ - ...filters, - [filterId]: undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Get options for a specific filter - const getFilterOptions = (filterId: FilterId) => { - switch (filterId) { - case 'resourceKinds': - return resourceKinds - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'actorNames': - return actorNames - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'apiGroups': - return apiGroups - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'resourceNamespaces': - return resourceNamespaces - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'actions': - // TODO: Return action facets when backend supports it - return []; - default: - return []; - } - }; - - // Get values for a specific filter - const getFilterValues = (filterId: FilterId): string[] => { - const value = filters[filterId]; - if (filterId === 'resourceName') { - return value ? [value as string] : []; - } - if (filterId === 'actions') { - return (value as string[] | undefined) || []; - } - return (value as string[] | undefined) || []; - }; - - // Handle search input change with debouncing - const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const value = event.target.value; - onFiltersChange({ - ...filters, - search: value || undefined, - }); - }, - [filters, onFiltersChange] - ); - - return ( -
-
- {/* Change Source Toggle */} - {!hiddenFilters.includes('changeSource') && ( - - )} - - {/* Search Input */} -
- - -
- - {/* Active Filter Chips */} - {activeFilterIds.map((filterId) => { - const config = FILTER_CONFIGS[filterId]; - return ( - handleFilterChange(filterId, values)} - onClear={() => handleFilterClear(filterId)} - onPopoverClose={() => handlePopoverClose(filterId)} - inputMode={config.inputMode} - placeholder={config.placeholder} - searchPlaceholder={config.searchPlaceholder} - autoOpen={pendingFilter === filterId} - disabled={disabled} - /> - ); - })} - - {/* Add Filter Dropdown */} - 0} - disabled={disabled} - /> - - {/* Spacer */} -
- - {/* Time Range Dropdown */} - -
-
- ); -} +import { useState, useCallback, useEffect } from 'react'; +import { formatISO, subDays } from 'date-fns'; +import { Search } from 'lucide-react'; + +import type { ActivityFeedFilters as FilterState } from '../hooks/useActivityFeed'; +import type { TimeRange } from '../hooks/useActivityFeed'; +import type { ActivityApiClient } from '../api/client'; +import { useFacets } from '../hooks/useFacets'; +import { ChangeSourceToggle, ChangeSourceOption } from './ChangeSourceToggle'; +import { TimeRangeDropdown } from './ui/time-range-dropdown'; +import { FilterChip } from './ui/filter-chip'; +import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; +import { Input } from './ui/input'; + +export interface ActivityFeedFiltersProps { + /** API client instance for fetching facets */ + client: ActivityApiClient; + /** Current filter state */ + filters: FilterState; + /** Current time range */ + timeRange: TimeRange; + /** Handler called when filters change */ + onFiltersChange: (filters: FilterState) => void; + /** Handler called when time range changes */ + onTimeRangeChange: (timeRange: TimeRange) => void; + /** Whether the filters are disabled (e.g., during loading) */ + disabled?: boolean; + /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ + hiddenFilters?: Array<'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions' | 'changeSource'>; + /** Additional CSS class */ + className?: string; +} + +/** + * Preset time ranges + */ +const TIME_PRESETS = [ + { key: 'now-1h', label: 'Last hour' }, + { key: 'now-24h', label: 'Last 24 hours' }, + { key: 'now-7d', label: 'Last 7 days' }, + { key: 'now-30d', label: 'Last 30 days' }, +]; + +/** + * Filter configuration registry + */ +type FilterId = 'resourceKinds' | 'actorNames' | 'apiGroups' | 'resourceNamespaces' | 'resourceName' | 'actions'; + +interface FilterConfig { + id: FilterId; + label: string; + inputMode: 'typeahead' | 'text'; + placeholder?: string; + searchPlaceholder?: string; +} + +const FILTER_CONFIGS: Record = { + resourceKinds: { + id: 'resourceKinds', + label: 'Kind', + inputMode: 'typeahead', + searchPlaceholder: 'Search kinds...', + }, + actorNames: { + id: 'actorNames', + label: 'Actor', + inputMode: 'typeahead', + searchPlaceholder: 'Search actors...', + }, + apiGroups: { + id: 'apiGroups', + label: 'API Group', + inputMode: 'typeahead', + searchPlaceholder: 'Search API groups...', + }, + resourceNamespaces: { + id: 'resourceNamespaces', + label: 'Namespace', + inputMode: 'typeahead', + searchPlaceholder: 'Search namespaces...', + }, + resourceName: { + id: 'resourceName', + label: 'Resource Name', + inputMode: 'text', + placeholder: 'Enter resource name...', + }, + actions: { + id: 'actions', + label: 'Action', + inputMode: 'typeahead', + searchPlaceholder: 'Search actions...', + }, +}; + +/** + * Helper function to convert ISO string to datetime-local format + */ +const formatDatetimeLocal = (isoString: string): string => { + const date = new Date(isoString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +/** + * Check if the current time range matches a preset + */ +const getSelectedPreset = (timeRange: TimeRange): string => { + const preset = TIME_PRESETS.find((p) => timeRange.start === p.key); + return preset ? preset.key : 'custom'; +}; + +/** + * ActivityFeedFilters provides filter controls for the activity feed + */ +export function ActivityFeedFilters({ + client, + filters, + timeRange, + onFiltersChange, + onTimeRangeChange, + disabled = false, + hiddenFilters = [], + className = '', +}: ActivityFeedFiltersProps) { + const { resourceKinds, actorNames, apiGroups, resourceNamespaces, error: facetsError } = useFacets(client, timeRange, filters); + + // Log facets error for debugging + if (facetsError) { + console.error('Failed to load facets:', facetsError); + } + + // Track which filter was just added to auto-open it + const [pendingFilter, setPendingFilter] = useState(null); + + // Custom time range state + const selectedPreset = getSelectedPreset(timeRange); + const [customStart, setCustomStart] = useState(() => { + if (selectedPreset === 'custom') { + return formatDatetimeLocal(timeRange.start); + } + return formatDatetimeLocal(formatISO(subDays(new Date(), 1))); + }); + const [customEnd, setCustomEnd] = useState(() => { + if (selectedPreset === 'custom' && timeRange.end) { + return formatDatetimeLocal(timeRange.end); + } + return formatDatetimeLocal(formatISO(new Date())); + }); + + // Handle change source change + const handleChangeSourceChange = useCallback( + (value: ChangeSourceOption) => { + onFiltersChange({ + ...filters, + changeSource: value, + }); + }, + [filters, onFiltersChange] + ); + + // Handle time range preset selection + const handleTimePresetSelect = useCallback( + (presetKey: string) => { + onTimeRangeChange({ + start: presetKey, + end: undefined, + }); + }, + [onTimeRangeChange] + ); + + // Handle custom time range apply + const handleCustomRangeApply = useCallback( + (start: string, end: string) => { + setCustomStart(start); + setCustomEnd(end); + onTimeRangeChange({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }); + }, + [onTimeRangeChange] + ); + + // Get display label for time range + const getTimeRangeLabel = () => { + const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); + if (preset) return preset.label; + if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { + const start = new Date(timeRange.start); + const end = new Date(timeRange.end); + return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; + } + return 'Select time range'; + }; + + // Determine which filters are currently active (have values) and not hidden + const filtersWithValues: FilterId[] = []; + if (filters.resourceKinds && filters.resourceKinds.length > 0 && !hiddenFilters.includes('resourceKinds')) filtersWithValues.push('resourceKinds'); + if (filters.actorNames && filters.actorNames.length > 0 && !hiddenFilters.includes('actorNames')) filtersWithValues.push('actorNames'); + if (filters.apiGroups && filters.apiGroups.length > 0 && !hiddenFilters.includes('apiGroups')) filtersWithValues.push('apiGroups'); + if (filters.resourceNamespaces && filters.resourceNamespaces.length > 0 && !hiddenFilters.includes('resourceNamespaces')) filtersWithValues.push('resourceNamespaces'); + if (filters.resourceName && !hiddenFilters.includes('resourceName')) filtersWithValues.push('resourceName'); + if (filters.actions && filters.actions.length > 0) filtersWithValues.push('actions'); + + // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters + const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) + ? [...filtersWithValues, pendingFilter] + : filtersWithValues; + + // Clear pending filter when filter values change (user selected something) + useEffect(() => { + if (pendingFilter && filtersWithValues.includes(pendingFilter)) { + // Filter now has values, clear pending state + setPendingFilter(null); + } + }, [pendingFilter, filtersWithValues]); + + // Build available filters list (exclude hidden filters) + const availableFilters: FilterOption[] = [ + { id: 'resourceKinds', label: 'Kind' }, + { id: 'actorNames', label: 'Actor' }, + { id: 'apiGroups', label: 'API Group' }, + { id: 'resourceNamespaces', label: 'Namespace' }, + { id: 'resourceName', label: 'Resource Name' }, + { id: 'actions', label: 'Action' }, + ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); + + // Handle adding a filter + const handleAddFilter = useCallback((filterId: string) => { + setPendingFilter(filterId as FilterId); + }, []); + + // Handle popover close - clear pending filter if no values were selected + const handlePopoverClose = useCallback( + (filterId: FilterId) => { + if (pendingFilter === filterId) { + const hasValues = (() => { + const value = filters[filterId]; + if (filterId === 'resourceName') return !!value; + return Array.isArray(value) && value.length > 0; + })(); + if (!hasValues) { + setPendingFilter(null); + } + } + }, + [pendingFilter, filters] + ); + + // Handle filter value changes + const handleFilterChange = useCallback( + (filterId: FilterId, values: string[]) => { + onFiltersChange({ + ...filters, + [filterId]: values.length > 0 ? values : undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Handle filter clear + const handleFilterClear = useCallback( + (filterId: FilterId) => { + onFiltersChange({ + ...filters, + [filterId]: undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Get options for a specific filter + const getFilterOptions = (filterId: FilterId) => { + switch (filterId) { + case 'resourceKinds': + return resourceKinds + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'actorNames': + return actorNames + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'apiGroups': + return apiGroups + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'resourceNamespaces': + return resourceNamespaces + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'actions': + // TODO: Return action facets when backend supports it + return []; + default: + return []; + } + }; + + // Get values for a specific filter + const getFilterValues = (filterId: FilterId): string[] => { + const value = filters[filterId]; + if (filterId === 'resourceName') { + return value ? [value as string] : []; + } + if (filterId === 'actions') { + return (value as string[] | undefined) || []; + } + return (value as string[] | undefined) || []; + }; + + // Handle search input change with debouncing + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + onFiltersChange({ + ...filters, + search: value || undefined, + }); + }, + [filters, onFiltersChange] + ); + + return ( +
+
+ {/* Change Source Toggle */} + {!hiddenFilters.includes('changeSource') && ( + + )} + + {/* Search Input */} +
+ + +
+ + {/* Active Filter Chips */} + {activeFilterIds.map((filterId) => { + const config = FILTER_CONFIGS[filterId]; + return ( + handleFilterChange(filterId, values)} + onClear={() => handleFilterClear(filterId)} + onPopoverClose={() => handlePopoverClose(filterId)} + inputMode={config.inputMode} + placeholder={config.placeholder} + searchPlaceholder={config.searchPlaceholder} + autoOpen={pendingFilter === filterId} + disabled={disabled} + /> + ); + })} + + {/* Add Filter Dropdown */} + 0} + disabled={disabled} + /> + + {/* Spacer */} +
+ + {/* Time Range Dropdown */} + +
+
+ ); +} diff --git a/ui/src/components/ActivityFeedItem.tsx b/ui/src/components/ActivityFeedItem.tsx index 27b2144c..9caf3dd6 100644 --- a/ui/src/components/ActivityFeedItem.tsx +++ b/ui/src/components/ActivityFeedItem.tsx @@ -1,361 +1,361 @@ -import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; -import type { Activity, ResourceLinkResolver, TenantLinkResolver, TenantRenderer } from '../types/activity'; -import { ActivityFeedSummary, ResourceLinkClickHandler } from './ActivityFeedSummary'; -import { ActivityExpandedDetails } from './ActivityExpandedDetails'; -import { TenantBadge } from './TenantBadge'; -import { cn } from '../lib/utils'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; - -export interface ActivityFeedItemProps { - /** The activity to render */ - activity: Activity; - /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ - onResourceClick?: ResourceLinkClickHandler; - /** Function that resolves resource references to URLs */ - resourceLinkResolver?: ResourceLinkResolver; - /** Function that resolves tenant references to URLs */ - tenantLinkResolver?: TenantLinkResolver; - /** Custom renderer for tenant badges (overrides default TenantBadge) */ - tenantRenderer?: TenantRenderer; - /** Handler called when the actor name or avatar is clicked */ - onActorClick?: (actorName: string) => void; - /** Handler called when the item is clicked */ - onActivityClick?: (activity: Activity) => void; - /** Whether the item is selected */ - isSelected?: boolean; - /** Additional CSS class */ - className?: string; - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Whether this is a newly streamed activity */ - isNew?: boolean; - /** Layout variant: 'feed' (default) or 'timeline' */ - variant?: 'feed' | 'timeline'; - /** Whether this is the first item in the list (hides timeline head, only used in timeline variant) */ - isFirst?: boolean; - /** Whether this is the last item in the list (hides timeline tail, only used in timeline variant) */ - isLast?: boolean; - /** Whether the item starts expanded */ - defaultExpanded?: boolean; -} - -/** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * Get avatar initials from actor name - */ -function getActorInitials(name: string): string { - const parts = name.split(/[@\s.]+/).filter(Boolean); - if (parts.length === 0) return '?'; - if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); - return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); -} - -/** - * Get Tailwind classes for actor avatar based on actor type - */ -function getActorAvatarClasses(actorType: string, compact: boolean): string { - const baseClasses = cn( - 'rounded-full flex items-center justify-center shrink-0 font-semibold', - compact ? 'w-5 h-5 text-xs' : 'w-6 h-6 text-xs' - ); - switch (actorType) { - case 'user': - return cn(baseClasses, 'bg-lime-200 text-slate-900 dark:bg-lime-800 dark:text-lime-100'); - case 'controller': - return cn(baseClasses, 'bg-rose-300 text-slate-900 dark:bg-rose-800 dark:text-rose-100'); - case 'machine account': - return cn(baseClasses, 'bg-muted text-muted-foreground'); - default: - return cn(baseClasses, 'bg-muted text-muted-foreground'); - } -} - -/** - * Extract verb from activity summary (e.g., "alice created HTTPProxy" -> "created") - */ -function extractVerb(summary: string): string { - const words = summary.split(/\s+/); - if (words.length >= 2) { - return words[1].toLowerCase(); - } - return 'unknown'; -} - -/** - * Normalize verb to a canonical form for coloring - */ -function normalizeVerb(verb: string): 'create' | 'update' | 'delete' | 'other' { - const normalized = verb.toLowerCase(); - if (normalized.includes('create') || normalized.includes('add')) return 'create'; - if (normalized.includes('delete') || normalized.includes('remove')) return 'delete'; - if (normalized.includes('update') || normalized.includes('patch') || normalized.includes('modify') || normalized.includes('change') || normalized.includes('edit')) return 'update'; - return 'other'; -} - -/** - * Get timeline node classes based on verb - */ -function getTimelineNodeClasses(verb: string): string { - const normalizedVerb = normalizeVerb(verb); - switch (normalizedVerb) { - case 'create': - return 'bg-green-500'; - case 'update': - return 'bg-amber-500'; - case 'delete': - return 'bg-red-500'; - default: - return 'bg-muted-foreground'; - } -} - -/** - * ActivityFeedItem renders a single activity in the feed or timeline - */ -export function ActivityFeedItem({ - activity, - onResourceClick, - resourceLinkResolver, - tenantLinkResolver, - tenantRenderer, - onActorClick, - onActivityClick, - isSelected = false, - className = '', - compact = false, - isNew = false, - variant = 'feed', - isFirst = false, - isLast = false, - defaultExpanded = false, -}: ActivityFeedItemProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - const { spec, metadata } = activity; - const { actor, summary, links, tenant } = spec; - - const handleClick = () => { - onActivityClick?.(activity); - }; - - const handleActorClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (onActorClick) { - onActorClick(actor.name); - } - }; - - const toggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded(!isExpanded); - }; - - const timestamp = metadata?.creationTimestamp; - const verb = extractVerb(summary); - const isTimeline = variant === 'timeline'; - - // Timeline variant wrapper - if (isTimeline) { - return ( -
- {/* Timeline column - contains line and dot */} -
- {/* Top line segment (connects to previous item) */} -
- - {/* Timeline node (dot) - centered */} -
- - {/* Bottom line segment (connects to next item) */} -
-
- - {/* Event content card */} -
- {/* Single row layout */} -
- {/* Summary - takes remaining space */} -
- -
- - {/* Tenant badge */} - {tenant && ( -
- {tenantRenderer ? tenantRenderer(tenant) : } -
- )} - - {/* Timestamp */} - - {formatTimestamp(timestamp)} - - - {/* Expand button */} - -
- - {/* Expanded Details */} - {isExpanded && } -
-
- ); - } - - // Feed variant (single-row layout) - return ( - - {/* Single row layout */} -
- {/* Actor Avatar */} -
- {actor.type === 'controller' ? ( - - ) : actor.type === 'machine account' ? ( - 🤖 - ) : ( - {getActorInitials(actor.name)} - )} -
- - {/* Summary - takes remaining space */} -
- -
- - {/* Tenant badge */} - {tenant && ( -
- {tenantRenderer ? tenantRenderer(tenant) : } -
- )} - - {/* Timestamp */} - - {formatTimestamp(timestamp)} - - - {/* Expand button */} - -
- - {/* Expanded Details */} - {isExpanded && } -
- ); -} +import { useState } from 'react'; +import { format, formatDistanceToNow } from 'date-fns'; +import type { Activity, ResourceLinkResolver, TenantLinkResolver, TenantRenderer } from '../types/activity'; +import { ActivityFeedSummary, ResourceLinkClickHandler } from './ActivityFeedSummary'; +import { ActivityExpandedDetails } from './ActivityExpandedDetails'; +import { TenantBadge } from './TenantBadge'; +import { cn } from '../lib/utils'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; + +export interface ActivityFeedItemProps { + /** The activity to render */ + activity: Activity; + /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ + onResourceClick?: ResourceLinkClickHandler; + /** Function that resolves resource references to URLs */ + resourceLinkResolver?: ResourceLinkResolver; + /** Function that resolves tenant references to URLs */ + tenantLinkResolver?: TenantLinkResolver; + /** Custom renderer for tenant badges (overrides default TenantBadge) */ + tenantRenderer?: TenantRenderer; + /** Handler called when the actor name or avatar is clicked */ + onActorClick?: (actorName: string) => void; + /** Handler called when the item is clicked */ + onActivityClick?: (activity: Activity) => void; + /** Whether the item is selected */ + isSelected?: boolean; + /** Additional CSS class */ + className?: string; + /** Whether to show as compact (for resource detail tabs) */ + compact?: boolean; + /** Whether this is a newly streamed activity */ + isNew?: boolean; + /** Layout variant: 'feed' (default) or 'timeline' */ + variant?: 'feed' | 'timeline'; + /** Whether this is the first item in the list (hides timeline head, only used in timeline variant) */ + isFirst?: boolean; + /** Whether this is the last item in the list (hides timeline tail, only used in timeline variant) */ + isLast?: boolean; + /** Whether the item starts expanded */ + defaultExpanded?: boolean; +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + const date = new Date(timestamp); + return formatDistanceToNow(date, { addSuffix: true }); + } catch { + return timestamp; + } +} + +/** + * Format timestamp for tooltip (with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + } catch { + return timestamp; + } +} + +/** + * Get avatar initials from actor name + */ +function getActorInitials(name: string): string { + const parts = name.split(/[@\s.]+/).filter(Boolean); + if (parts.length === 0) return '?'; + if (parts.length === 1) return parts[0].charAt(0).toUpperCase(); + return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase(); +} + +/** + * Get Tailwind classes for actor avatar based on actor type + */ +function getActorAvatarClasses(actorType: string, compact: boolean): string { + const baseClasses = cn( + 'rounded-full flex items-center justify-center shrink-0 font-semibold', + compact ? 'w-5 h-5 text-xs' : 'w-6 h-6 text-xs' + ); + switch (actorType) { + case 'user': + return cn(baseClasses, 'bg-lime-200 text-slate-900 dark:bg-lime-800 dark:text-lime-100'); + case 'controller': + return cn(baseClasses, 'bg-rose-300 text-slate-900 dark:bg-rose-800 dark:text-rose-100'); + case 'machine account': + return cn(baseClasses, 'bg-muted text-muted-foreground'); + default: + return cn(baseClasses, 'bg-muted text-muted-foreground'); + } +} + +/** + * Extract verb from activity summary (e.g., "alice created HTTPProxy" -> "created") + */ +function extractVerb(summary: string): string { + const words = summary.split(/\s+/); + if (words.length >= 2) { + return words[1].toLowerCase(); + } + return 'unknown'; +} + +/** + * Normalize verb to a canonical form for coloring + */ +function normalizeVerb(verb: string): 'create' | 'update' | 'delete' | 'other' { + const normalized = verb.toLowerCase(); + if (normalized.includes('create') || normalized.includes('add')) return 'create'; + if (normalized.includes('delete') || normalized.includes('remove')) return 'delete'; + if (normalized.includes('update') || normalized.includes('patch') || normalized.includes('modify') || normalized.includes('change') || normalized.includes('edit')) return 'update'; + return 'other'; +} + +/** + * Get timeline node classes based on verb + */ +function getTimelineNodeClasses(verb: string): string { + const normalizedVerb = normalizeVerb(verb); + switch (normalizedVerb) { + case 'create': + return 'bg-green-500'; + case 'update': + return 'bg-amber-500'; + case 'delete': + return 'bg-red-500'; + default: + return 'bg-muted-foreground'; + } +} + +/** + * ActivityFeedItem renders a single activity in the feed or timeline + */ +export function ActivityFeedItem({ + activity, + onResourceClick, + resourceLinkResolver, + tenantLinkResolver, + tenantRenderer, + onActorClick, + onActivityClick, + isSelected = false, + className = '', + compact = false, + isNew = false, + variant = 'feed', + isFirst = false, + isLast = false, + defaultExpanded = false, +}: ActivityFeedItemProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + const { spec, metadata } = activity; + const { actor, summary, links, tenant } = spec; + + const handleClick = () => { + onActivityClick?.(activity); + }; + + const handleActorClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onActorClick) { + onActorClick(actor.name); + } + }; + + const toggleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + const timestamp = metadata?.creationTimestamp; + const verb = extractVerb(summary); + const isTimeline = variant === 'timeline'; + + // Timeline variant wrapper + if (isTimeline) { + return ( +
+ {/* Timeline column - contains line and dot */} +
+ {/* Top line segment (connects to previous item) */} +
+ + {/* Timeline node (dot) - centered */} +
+ + {/* Bottom line segment (connects to next item) */} +
+
+ + {/* Event content card */} +
+ {/* Single row layout */} +
+ {/* Summary - takes remaining space */} +
+ +
+ + {/* Tenant badge */} + {tenant && ( +
+ {tenantRenderer ? tenantRenderer(tenant) : } +
+ )} + + {/* Timestamp */} + + {formatTimestamp(timestamp)} + + + {/* Expand button */} + +
+ + {/* Expanded Details */} + {isExpanded && } +
+
+ ); + } + + // Feed variant (single-row layout) + return ( + + {/* Single row layout */} +
+ {/* Actor Avatar */} +
+ {actor.type === 'controller' ? ( + + ) : actor.type === 'machine account' ? ( + 🤖 + ) : ( + {getActorInitials(actor.name)} + )} +
+ + {/* Summary - takes remaining space */} +
+ +
+ + {/* Tenant badge */} + {tenant && ( +
+ {tenantRenderer ? tenantRenderer(tenant) : } +
+ )} + + {/* Timestamp */} + + {formatTimestamp(timestamp)} + + + {/* Expand button */} + +
+ + {/* Expanded Details */} + {isExpanded && } +
+ ); +} diff --git a/ui/src/components/ActivityFeedItemSkeleton.tsx b/ui/src/components/ActivityFeedItemSkeleton.tsx index dae319a7..cfedf49d 100644 --- a/ui/src/components/ActivityFeedItemSkeleton.tsx +++ b/ui/src/components/ActivityFeedItemSkeleton.tsx @@ -1,48 +1,48 @@ -import { Card } from './ui/card'; -import { Skeleton } from './ui/skeleton'; -import { cn } from '../lib/utils'; - -export interface ActivityFeedItemSkeletonProps { - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Additional CSS class */ - className?: string; -} - -/** - * ActivityFeedItemSkeleton renders a loading placeholder that matches ActivityFeedItem layout - */ -export function ActivityFeedItemSkeleton({ - compact = false, - className = '', -}: ActivityFeedItemSkeletonProps) { - return ( - - {/* Single row layout */} -
- {/* Actor Avatar skeleton */} - - - {/* Summary skeleton - takes remaining space */} - - - {/* Tenant badge skeleton */} - - - {/* Timestamp skeleton */} - - - {/* Expand button skeleton */} - -
-
- ); -} +import { Card } from './ui/card'; +import { Skeleton } from './ui/skeleton'; +import { cn } from '../lib/utils'; + +export interface ActivityFeedItemSkeletonProps { + /** Whether to show as compact (for resource detail tabs) */ + compact?: boolean; + /** Additional CSS class */ + className?: string; +} + +/** + * ActivityFeedItemSkeleton renders a loading placeholder that matches ActivityFeedItem layout + */ +export function ActivityFeedItemSkeleton({ + compact = false, + className = '', +}: ActivityFeedItemSkeletonProps) { + return ( + + {/* Single row layout */} +
+ {/* Actor Avatar skeleton */} + + + {/* Summary skeleton - takes remaining space */} + + + {/* Tenant badge skeleton */} + + + {/* Timestamp skeleton */} + + + {/* Expand button skeleton */} + +
+
+ ); +} diff --git a/ui/src/components/ActivityFeedSummary.tsx b/ui/src/components/ActivityFeedSummary.tsx index e9ec930c..b3aa3855 100644 --- a/ui/src/components/ActivityFeedSummary.tsx +++ b/ui/src/components/ActivityFeedSummary.tsx @@ -1,156 +1,156 @@ -import type { ActivityLink, ResourceRef, ResourceLinkResolver, ResourceLinkContext } from '../types/activity'; - -export interface ResourceLinkClickHandler { - (resource: ResourceRef): void; -} - -export interface ActivityFeedSummaryProps { - /** The summary text to render */ - summary: string; - /** Links within the summary to make clickable */ - links?: ActivityLink[]; - /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ - onResourceClick?: ResourceLinkClickHandler; - /** Function that resolves resource references to URLs (renders as tags) */ - resourceLinkResolver?: ResourceLinkResolver; - /** Context for resolving resource links (includes tenant information) */ - resourceLinkContext?: ResourceLinkContext; - /** Additional CSS class */ - className?: string; -} - -/** - * Parse summary text and replace marker strings with clickable links - */ -function parseSummaryWithLinks( - summary: string, - links: ActivityLink[] | undefined, - onResourceClick?: ResourceLinkClickHandler, - resourceLinkResolver?: ResourceLinkResolver, - resourceLinkContext?: ResourceLinkContext -): (string | JSX.Element)[] { - if (!links || links.length === 0) { - return [summary]; - } - - // Sort links by marker length (longest first) to avoid partial matches - const sortedLinks = [...links].sort((a, b) => b.marker.length - a.marker.length); - - // Track positions that have been replaced - interface ReplacedRange { - start: number; - end: number; - link: ActivityLink; - } - - const replacedRanges: ReplacedRange[] = []; - - // Find all marker positions - for (const link of sortedLinks) { - let searchStart = 0; - let pos = summary.indexOf(link.marker, searchStart); - - while (pos !== -1) { - const end = pos + link.marker.length; - - // Check if this range overlaps with any existing range - const overlaps = replacedRanges.some( - (range) => pos < range.end && end > range.start - ); - - if (!overlaps) { - replacedRanges.push({ start: pos, end, link }); - } - - searchStart = pos + 1; - pos = summary.indexOf(link.marker, searchStart); - } - } - - // Sort ranges by start position - replacedRanges.sort((a, b) => a.start - b.start); - - // Build the result array - const result: (string | JSX.Element)[] = []; - let lastEnd = 0; - - for (let i = 0; i < replacedRanges.length; i++) { - const range = replacedRanges[i]; - - // Add text before this marker - if (range.start > lastEnd) { - result.push(summary.substring(lastEnd, range.start)); - } - - // If resourceLinkResolver is provided, render as tag - if (resourceLinkResolver) { - const url = resourceLinkResolver(range.link.resource, resourceLinkContext); - if (url) { - result.push( - e.stopPropagation()} - > - {range.link.marker} - - ); - } else { - // Resolver returned undefined, render as plain text - result.push(range.link.marker); - } - } else { - // Fallback to button with onResourceClick handler for backward compatibility - const handleClick = onResourceClick - ? (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - onResourceClick(range.link.resource); - } - : undefined; - - result.push( - - ); - } - - lastEnd = range.end; - } - - // Add any remaining text - if (lastEnd < summary.length) { - result.push(summary.substring(lastEnd)); - } - - return result; -} - -/** - * ActivityFeedSummary renders an activity summary with clickable resource links - */ -export function ActivityFeedSummary({ - summary, - links, - onResourceClick, - resourceLinkResolver, - resourceLinkContext, - className = '', -}: ActivityFeedSummaryProps) { - const parsedContent = parseSummaryWithLinks(summary, links, onResourceClick, resourceLinkResolver, resourceLinkContext); - - return ( - - {parsedContent} - - ); -} +import type { ActivityLink, ResourceRef, ResourceLinkResolver, ResourceLinkContext } from '../types/activity'; + +export interface ResourceLinkClickHandler { + (resource: ResourceRef): void; +} + +export interface ActivityFeedSummaryProps { + /** The summary text to render */ + summary: string; + /** Links within the summary to make clickable */ + links?: ActivityLink[]; + /** Handler called when a resource link is clicked (deprecated: use resourceLinkResolver) */ + onResourceClick?: ResourceLinkClickHandler; + /** Function that resolves resource references to URLs (renders as tags) */ + resourceLinkResolver?: ResourceLinkResolver; + /** Context for resolving resource links (includes tenant information) */ + resourceLinkContext?: ResourceLinkContext; + /** Additional CSS class */ + className?: string; +} + +/** + * Parse summary text and replace marker strings with clickable links + */ +function parseSummaryWithLinks( + summary: string, + links: ActivityLink[] | undefined, + onResourceClick?: ResourceLinkClickHandler, + resourceLinkResolver?: ResourceLinkResolver, + resourceLinkContext?: ResourceLinkContext +): (string | JSX.Element)[] { + if (!links || links.length === 0) { + return [summary]; + } + + // Sort links by marker length (longest first) to avoid partial matches + const sortedLinks = [...links].sort((a, b) => b.marker.length - a.marker.length); + + // Track positions that have been replaced + interface ReplacedRange { + start: number; + end: number; + link: ActivityLink; + } + + const replacedRanges: ReplacedRange[] = []; + + // Find all marker positions + for (const link of sortedLinks) { + let searchStart = 0; + let pos = summary.indexOf(link.marker, searchStart); + + while (pos !== -1) { + const end = pos + link.marker.length; + + // Check if this range overlaps with any existing range + const overlaps = replacedRanges.some( + (range) => pos < range.end && end > range.start + ); + + if (!overlaps) { + replacedRanges.push({ start: pos, end, link }); + } + + searchStart = pos + 1; + pos = summary.indexOf(link.marker, searchStart); + } + } + + // Sort ranges by start position + replacedRanges.sort((a, b) => a.start - b.start); + + // Build the result array + const result: (string | JSX.Element)[] = []; + let lastEnd = 0; + + for (let i = 0; i < replacedRanges.length; i++) { + const range = replacedRanges[i]; + + // Add text before this marker + if (range.start > lastEnd) { + result.push(summary.substring(lastEnd, range.start)); + } + + // If resourceLinkResolver is provided, render as tag + if (resourceLinkResolver) { + const url = resourceLinkResolver(range.link.resource, resourceLinkContext); + if (url) { + result.push( + e.stopPropagation()} + > + {range.link.marker} + + ); + } else { + // Resolver returned undefined, render as plain text + result.push(range.link.marker); + } + } else { + // Fallback to button with onResourceClick handler for backward compatibility + const handleClick = onResourceClick + ? (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onResourceClick(range.link.resource); + } + : undefined; + + result.push( + + ); + } + + lastEnd = range.end; + } + + // Add any remaining text + if (lastEnd < summary.length) { + result.push(summary.substring(lastEnd)); + } + + return result; +} + +/** + * ActivityFeedSummary renders an activity summary with clickable resource links + */ +export function ActivityFeedSummary({ + summary, + links, + onResourceClick, + resourceLinkResolver, + resourceLinkContext, + className = '', +}: ActivityFeedSummaryProps) { + const parsedContent = parseSummaryWithLinks(summary, links, onResourceClick, resourceLinkResolver, resourceLinkContext); + + return ( + + {parsedContent} + + ); +} diff --git a/ui/src/components/ApiErrorAlert.tsx b/ui/src/components/ApiErrorAlert.tsx index 1aae8ed9..c1fc62d4 100644 --- a/ui/src/components/ApiErrorAlert.tsx +++ b/ui/src/components/ApiErrorAlert.tsx @@ -1,62 +1,62 @@ -import { AlertCircle, AlertTriangle, RotateCw } from 'lucide-react'; -import { Alert, AlertDescription } from './ui/alert'; -import { Button } from './ui/button'; -import { defaultErrorFormatter } from '../lib/errors'; -import type { ErrorFormatter } from '../types/activity'; - -export interface ApiErrorAlertProps { - error: Error | null; - onRetry?: () => void; - className?: string; - /** Custom error formatter for customizing error messages */ - errorFormatter?: ErrorFormatter; -} - -type FriendlyError = { - friendlyTitle: string; - friendlyMessage: string; - suggestion?: string | null; - severity: 'warning' | 'error'; -}; - -function isFriendlyError(error: Error): error is Error & FriendlyError { - return 'friendlyTitle' in error && 'friendlyMessage' in error && 'severity' in error; -} - -export function ApiErrorAlert({ error, onRetry, className, errorFormatter }: ApiErrorAlertProps) { - if (!error) return null; - - // Use custom formatter if provided, otherwise use default - const formatter = errorFormatter || defaultErrorFormatter; - const formatted = formatter(error); - - // Determine error details - const isFriendly = isFriendlyError(error); - const message = formatted.message; - const severity = isFriendly ? error.severity : 'error'; - - // Choose alert variant and icon based on severity - const alertVariant = severity === 'warning' ? 'warning' : 'destructive'; - const Icon = severity === 'warning' ? AlertTriangle : AlertCircle; - - return ( - svg]:top-2.5 [&>svg]:left-3 ${className || ''}`}> - - - {message} - {onRetry && ( - - )} - - - ); -} +import { AlertCircle, AlertTriangle, RotateCw } from 'lucide-react'; +import { Alert, AlertDescription } from './ui/alert'; +import { Button } from './ui/button'; +import { defaultErrorFormatter } from '../lib/errors'; +import type { ErrorFormatter } from '../types/activity'; + +export interface ApiErrorAlertProps { + error: Error | null; + onRetry?: () => void; + className?: string; + /** Custom error formatter for customizing error messages */ + errorFormatter?: ErrorFormatter; +} + +type FriendlyError = { + friendlyTitle: string; + friendlyMessage: string; + suggestion?: string | null; + severity: 'warning' | 'error'; +}; + +function isFriendlyError(error: Error): error is Error & FriendlyError { + return 'friendlyTitle' in error && 'friendlyMessage' in error && 'severity' in error; +} + +export function ApiErrorAlert({ error, onRetry, className, errorFormatter }: ApiErrorAlertProps) { + if (!error) return null; + + // Use custom formatter if provided, otherwise use default + const formatter = errorFormatter || defaultErrorFormatter; + const formatted = formatter(error); + + // Determine error details + const isFriendly = isFriendlyError(error); + const message = formatted.message; + const severity = isFriendly ? error.severity : 'error'; + + // Choose alert variant and icon based on severity + const alertVariant = severity === 'warning' ? 'warning' : 'destructive'; + const Icon = severity === 'warning' ? AlertTriangle : AlertCircle; + + return ( + svg]:top-2.5 [&>svg]:left-3 ${className || ''}`}> + + + {message} + {onRetry && ( + + )} + + + ); +} diff --git a/ui/src/components/AuditEventViewer.tsx b/ui/src/components/AuditEventViewer.tsx index 61ca6fea..f7285e95 100644 --- a/ui/src/components/AuditEventViewer.tsx +++ b/ui/src/components/AuditEventViewer.tsx @@ -1,273 +1,273 @@ -import { useState } from 'react'; -import { format } from 'date-fns'; -import type { Event } from '../types'; -import type { Tenant, TenantLinkResolver, TenantType } from '../types/activity'; -import { TenantBadge } from './TenantBadge'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; - -export interface AuditEventViewerProps { - events: Event[]; - className?: string; - onEventSelect?: (event: Event) => void; - /** Optional resolver function to make tenant badges clickable */ - tenantLinkResolver?: TenantLinkResolver; -} - -/** - * Extract tenant information from audit event annotations if present - * Expected annotations: tenant.type and tenant.name - */ -function extractTenantFromAnnotations(event: Event): Tenant | undefined { - const annotations = event.annotations; - if (!annotations) return undefined; - - const tenantType = annotations['tenant.type']; - const tenantName = annotations['tenant.name']; - - if (tenantType && tenantName) { - const validTypes: TenantType[] = ['platform', 'organization', 'project', 'user']; - if (validTypes.includes(tenantType as TenantType)) { - return { - type: tenantType as Tenant['type'], - name: tenantName, - }; - } - } - - return undefined; -} - -/** - * AuditEventViewer displays a list of audit events with details - */ -export function AuditEventViewer({ - events, - className = '', - onEventSelect, - tenantLinkResolver, -}: AuditEventViewerProps) { - const [selectedEvent, setSelectedEvent] = useState(null); - const [expandedEvents, setExpandedEvents] = useState>(new Set()); - - const toggleEventExpansion = (auditId: string) => { - const newExpanded = new Set(expandedEvents); - if (expandedEvents.has(auditId)) { - newExpanded.delete(auditId); - } else { - newExpanded.add(auditId); - } - setExpandedEvents(newExpanded); - }; - - const handleEventClick = (event: Event) => { - setSelectedEvent(event); - if (onEventSelect) { - onEventSelect(event); - } - }; - - const formatTimestamp = (timestamp?: string) => { - if (!timestamp) return 'N/A'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss'); - } catch { - return timestamp; - } - }; - - const getVerbBadgeVariant = (verb?: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' => { - switch (verb?.toLowerCase()) { - case 'create': - return 'success'; - case 'update': - case 'patch': - return 'warning'; - case 'delete': - return 'destructive'; - case 'get': - case 'list': - case 'watch': - return 'default'; - default: - return 'secondary'; - } - }; - - if (events.length === 0) { - return ( -
-
No events found
-
- ); - } - - return ( -
-
- {events.map((event) => { - const auditId = event.auditID || ''; - const isExpanded = expandedEvents.has(auditId); - const tenant = extractTenantFromAnnotations(event); - - return ( - handleEventClick(event)} - > -
-
- - {event.verb?.toUpperCase() || 'UNKNOWN'} - - - {event.objectRef?.resource || 'N/A'} - - {event.objectRef?.namespace && ( - - ns: {event.objectRef.namespace} - - )} - {event.objectRef?.name && ( - {event.objectRef.name} - )} - {tenant && ( - - )} -
-
- {event.user?.username || 'N/A'} - - {formatTimestamp(event.stageTimestamp)} - - -
-
- - {isExpanded && ( -
-
-

Event Information

-
-
Audit ID:
-
{event.auditID || 'N/A'}
-
Stage:
-
{event.stage || 'N/A'}
-
Level:
-
{event.level || 'N/A'}
-
Request URI:
-
{event.requestURI || 'N/A'}
- {event.userAgent && ( - <> -
User Agent:
-
{event.userAgent}
- - )} - {event.sourceIPs && event.sourceIPs.length > 0 && ( - <> -
Source IPs:
-
{event.sourceIPs.join(', ')}
- - )} -
-
- - {tenant && ( -
-

Tenant

- -
- )} - - {event.user && ( -
-

User Information

-
-
Username:
-
{event.user.username || 'N/A'}
-
UID:
-
{event.user.uid || 'N/A'}
- {event.user.groups && event.user.groups.length > 0 && ( - <> -
Groups:
-
{event.user.groups.join(', ')}
- - )} -
-
- )} - - {event.responseStatus && ( -
-

Response Status

-
-
Code:
-
{event.responseStatus.code || 'N/A'}
-
Status:
-
{event.responseStatus.status || 'N/A'}
- {event.responseStatus.message && ( - <> -
Message:
-
{event.responseStatus.message}
- - )} -
-
- )} - - {event.annotations && Object.keys(event.annotations).length > 0 && ( -
-

Annotations

-
- {Object.entries(event.annotations).map(([key, value]) => ( -
-
{key}:
-
{value}
-
- ))} -
-
- )} - - {(event.requestObject || event.responseObject) ? ( -
-

Request/Response Data

- {event.requestObject ? ( -
- Request Object -
{JSON.stringify(event.requestObject, null, 2)}
-
- ) : null} - {event.responseObject ? ( -
- Response Object -
{JSON.stringify(event.responseObject, null, 2)}
-
- ) : null} -
- ) : null} -
- )} -
- ); - })} -
-
- ); -} +import { useState } from 'react'; +import { format } from 'date-fns'; +import type { Event } from '../types'; +import type { Tenant, TenantLinkResolver, TenantType } from '../types/activity'; +import { TenantBadge } from './TenantBadge'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Badge } from './ui/badge'; + +export interface AuditEventViewerProps { + events: Event[]; + className?: string; + onEventSelect?: (event: Event) => void; + /** Optional resolver function to make tenant badges clickable */ + tenantLinkResolver?: TenantLinkResolver; +} + +/** + * Extract tenant information from audit event annotations if present + * Expected annotations: tenant.type and tenant.name + */ +function extractTenantFromAnnotations(event: Event): Tenant | undefined { + const annotations = event.annotations; + if (!annotations) return undefined; + + const tenantType = annotations['tenant.type']; + const tenantName = annotations['tenant.name']; + + if (tenantType && tenantName) { + const validTypes: TenantType[] = ['platform', 'organization', 'project', 'user']; + if (validTypes.includes(tenantType as TenantType)) { + return { + type: tenantType as Tenant['type'], + name: tenantName, + }; + } + } + + return undefined; +} + +/** + * AuditEventViewer displays a list of audit events with details + */ +export function AuditEventViewer({ + events, + className = '', + onEventSelect, + tenantLinkResolver, +}: AuditEventViewerProps) { + const [selectedEvent, setSelectedEvent] = useState(null); + const [expandedEvents, setExpandedEvents] = useState>(new Set()); + + const toggleEventExpansion = (auditId: string) => { + const newExpanded = new Set(expandedEvents); + if (expandedEvents.has(auditId)) { + newExpanded.delete(auditId); + } else { + newExpanded.add(auditId); + } + setExpandedEvents(newExpanded); + }; + + const handleEventClick = (event: Event) => { + setSelectedEvent(event); + if (onEventSelect) { + onEventSelect(event); + } + }; + + const formatTimestamp = (timestamp?: string) => { + if (!timestamp) return 'N/A'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss'); + } catch { + return timestamp; + } + }; + + const getVerbBadgeVariant = (verb?: string): 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' => { + switch (verb?.toLowerCase()) { + case 'create': + return 'success'; + case 'update': + case 'patch': + return 'warning'; + case 'delete': + return 'destructive'; + case 'get': + case 'list': + case 'watch': + return 'default'; + default: + return 'secondary'; + } + }; + + if (events.length === 0) { + return ( +
+
No events found
+
+ ); + } + + return ( +
+
+ {events.map((event) => { + const auditId = event.auditID || ''; + const isExpanded = expandedEvents.has(auditId); + const tenant = extractTenantFromAnnotations(event); + + return ( + handleEventClick(event)} + > +
+
+ + {event.verb?.toUpperCase() || 'UNKNOWN'} + + + {event.objectRef?.resource || 'N/A'} + + {event.objectRef?.namespace && ( + + ns: {event.objectRef.namespace} + + )} + {event.objectRef?.name && ( + {event.objectRef.name} + )} + {tenant && ( + + )} +
+
+ {event.user?.username || 'N/A'} + + {formatTimestamp(event.stageTimestamp)} + + +
+
+ + {isExpanded && ( +
+
+

Event Information

+
+
Audit ID:
+
{event.auditID || 'N/A'}
+
Stage:
+
{event.stage || 'N/A'}
+
Level:
+
{event.level || 'N/A'}
+
Request URI:
+
{event.requestURI || 'N/A'}
+ {event.userAgent && ( + <> +
User Agent:
+
{event.userAgent}
+ + )} + {event.sourceIPs && event.sourceIPs.length > 0 && ( + <> +
Source IPs:
+
{event.sourceIPs.join(', ')}
+ + )} +
+
+ + {tenant && ( +
+

Tenant

+ +
+ )} + + {event.user && ( +
+

User Information

+
+
Username:
+
{event.user.username || 'N/A'}
+
UID:
+
{event.user.uid || 'N/A'}
+ {event.user.groups && event.user.groups.length > 0 && ( + <> +
Groups:
+
{event.user.groups.join(', ')}
+ + )} +
+
+ )} + + {event.responseStatus && ( +
+

Response Status

+
+
Code:
+
{event.responseStatus.code || 'N/A'}
+
Status:
+
{event.responseStatus.status || 'N/A'}
+ {event.responseStatus.message && ( + <> +
Message:
+
{event.responseStatus.message}
+ + )} +
+
+ )} + + {event.annotations && Object.keys(event.annotations).length > 0 && ( +
+

Annotations

+
+ {Object.entries(event.annotations).map(([key, value]) => ( +
+
{key}:
+
{value}
+
+ ))} +
+
+ )} + + {(event.requestObject || event.responseObject) ? ( +
+

Request/Response Data

+ {event.requestObject ? ( +
+ Request Object +
{JSON.stringify(event.requestObject, null, 2)}
+
+ ) : null} + {event.responseObject ? ( +
+ Response Object +
{JSON.stringify(event.responseObject, null, 2)}
+
+ ) : null} +
+ ) : null} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/ui/src/components/AuditLogExpandedDetails.tsx b/ui/src/components/AuditLogExpandedDetails.tsx index b2a2c944..5a9555bd 100644 --- a/ui/src/components/AuditLogExpandedDetails.tsx +++ b/ui/src/components/AuditLogExpandedDetails.tsx @@ -1,290 +1,290 @@ -import { format } from 'date-fns'; -import type { Event } from '../types'; - -export interface AuditLogExpandedDetailsProps { - /** The audit event to display details for */ - event: Event; -} - -/** - * Format timestamp for display (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * AuditLogExpandedDetails renders the expanded details section for an audit log event. - * - * Section order (most to least relevant for investigation): - * 1. Request Summary (verb, URI) - * 2. Response Summary (status code with icon, message) - * 3. Timestamp (full) - * 4. User (username, UID, groups) - * 5. Resource (kind, name, namespace, API group) - * 6. Request Details (user agent, source IPs) - * 7. Advanced (collapsed) - audit ID, stage, level, annotations - * 8. Raw Objects (collapsed) - request/response JSON - */ -export function AuditLogExpandedDetails({ event }: AuditLogExpandedDetailsProps) { - const timestamp = event.stageTimestamp || event.requestReceivedTimestamp; - - return ( -
- {/* Request Summary */} -
-

- Request Summary -

-
-
Verb:
-
{event.verb || 'Unknown'}
- {event.requestURI && ( - <> -
URI:
-
{event.requestURI}
- - )} -
-
- - {/* Response Summary */} - {event.responseStatus && ( -
-

- Response Summary -

-
- {event.responseStatus.code !== undefined && ( - <> -
Status Code:
-
- = 200 && event.responseStatus.code < 300 - ? 'text-green-600 dark:text-green-400' - : 'text-red-600 dark:text-red-400' - } - > - {event.responseStatus.code >= 200 && event.responseStatus.code < 300 ? '✓ ' : '✗ '} - {event.responseStatus.code} - -
- - )} - {event.responseStatus.status && ( - <> -
Status:
-
{event.responseStatus.status}
- - )} - {event.responseStatus.message && ( - <> -
Message:
-
{event.responseStatus.message}
- - )} - {event.responseStatus.reason && ( - <> -
Reason:
-
{event.responseStatus.reason}
- - )} -
-
- )} - - {/* Timestamp */} -
-

- Timestamp -

-

- {formatTimestampFull(timestamp)} -

-
- - {/* User Information */} - {event.user ? ( -
-

- User -

-
- {event.user.username && ( - <> -
Username:
-
{event.user.username}
- - )} - {event.user.uid && ( - <> -
UID:
-
{event.user.uid}
- - )} - {event.user.groups && event.user.groups.length > 0 && ( - <> -
Groups:
-
- {event.user.groups.join(', ')} -
- - )} -
-
- ) : null} - - {/* Resource Information */} - {event.objectRef && ( -
-

- Resource -

-
- {event.objectRef.resource && ( - <> -
Kind:
-
{event.objectRef.resource}
- - )} - {event.objectRef.name && ( - <> -
Name:
-
{event.objectRef.name}
- - )} - {event.objectRef.namespace && ( - <> -
Namespace:
-
{event.objectRef.namespace}
- - )} - {event.objectRef.apiGroup && ( - <> -
API Group:
-
{event.objectRef.apiGroup}
- - )} - {event.objectRef.apiVersion && ( - <> -
API Version:
-
{event.objectRef.apiVersion}
- - )} - {event.objectRef.uid && ( - <> -
UID:
-
{event.objectRef.uid}
- - )} - {event.objectRef.subresource && ( - <> -
Subresource:
-
{event.objectRef.subresource}
- - )} -
-
- )} - - {/* Request Details */} - {(event.userAgent || (event.sourceIPs && event.sourceIPs.length > 0)) && ( -
-

- Request Details -

-
- {event.userAgent && ( - <> -
User Agent:
-
{event.userAgent}
- - )} - {event.sourceIPs && event.sourceIPs.length > 0 && ( - <> -
Source IPs:
-
{event.sourceIPs.join(', ')}
- - )} -
-
- )} - - {/* Advanced Details (collapsed) */} - {(event.auditID || event.stage || event.level || (event.annotations && Object.keys(event.annotations).length > 0)) && ( -
- -

- - Advanced -

-
-
-
- {event.auditID && ( - <> -
Audit ID:
-
{event.auditID}
- - )} - {event.stage && ( - <> -
Stage:
-
{event.stage}
- - )} - {event.level && ( - <> -
Level:
-
{event.level}
- - )} - {event.annotations && Object.entries(event.annotations).map(([key, value]) => ( -
-
{key}:
-
{value}
-
- ))} -
-
-
- )} - - {/* Raw Objects (collapsed) */} - {(event.requestObject || event.responseObject) ? ( -
- -

- - Raw Objects -

-
-
- {event.requestObject ? ( -
-
Request Object
-
-                  {JSON.stringify(event.requestObject, null, 2)}
-                
-
- ) : null} - {event.responseObject ? ( -
-
Response Object
-
-                  {JSON.stringify(event.responseObject, null, 2)}
-                
-
- ) : null} -
-
- ) : null} -
- ); -} +import { format } from 'date-fns'; +import type { Event } from '../types'; + +export interface AuditLogExpandedDetailsProps { + /** The audit event to display details for */ + event: Event; +} + +/** + * Format timestamp for display (with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + } catch { + return timestamp; + } +} + +/** + * AuditLogExpandedDetails renders the expanded details section for an audit log event. + * + * Section order (most to least relevant for investigation): + * 1. Request Summary (verb, URI) + * 2. Response Summary (status code with icon, message) + * 3. Timestamp (full) + * 4. User (username, UID, groups) + * 5. Resource (kind, name, namespace, API group) + * 6. Request Details (user agent, source IPs) + * 7. Advanced (collapsed) - audit ID, stage, level, annotations + * 8. Raw Objects (collapsed) - request/response JSON + */ +export function AuditLogExpandedDetails({ event }: AuditLogExpandedDetailsProps) { + const timestamp = event.stageTimestamp || event.requestReceivedTimestamp; + + return ( +
+ {/* Request Summary */} +
+

+ Request Summary +

+
+
Verb:
+
{event.verb || 'Unknown'}
+ {event.requestURI && ( + <> +
URI:
+
{event.requestURI}
+ + )} +
+
+ + {/* Response Summary */} + {event.responseStatus && ( +
+

+ Response Summary +

+
+ {event.responseStatus.code !== undefined && ( + <> +
Status Code:
+
+ = 200 && event.responseStatus.code < 300 + ? 'text-green-600 dark:text-green-400' + : 'text-red-600 dark:text-red-400' + } + > + {event.responseStatus.code >= 200 && event.responseStatus.code < 300 ? '✓ ' : '✗ '} + {event.responseStatus.code} + +
+ + )} + {event.responseStatus.status && ( + <> +
Status:
+
{event.responseStatus.status}
+ + )} + {event.responseStatus.message && ( + <> +
Message:
+
{event.responseStatus.message}
+ + )} + {event.responseStatus.reason && ( + <> +
Reason:
+
{event.responseStatus.reason}
+ + )} +
+
+ )} + + {/* Timestamp */} +
+

+ Timestamp +

+

+ {formatTimestampFull(timestamp)} +

+
+ + {/* User Information */} + {event.user ? ( +
+

+ User +

+
+ {event.user.username && ( + <> +
Username:
+
{event.user.username}
+ + )} + {event.user.uid && ( + <> +
UID:
+
{event.user.uid}
+ + )} + {event.user.groups && event.user.groups.length > 0 && ( + <> +
Groups:
+
+ {event.user.groups.join(', ')} +
+ + )} +
+
+ ) : null} + + {/* Resource Information */} + {event.objectRef && ( +
+

+ Resource +

+
+ {event.objectRef.resource && ( + <> +
Kind:
+
{event.objectRef.resource}
+ + )} + {event.objectRef.name && ( + <> +
Name:
+
{event.objectRef.name}
+ + )} + {event.objectRef.namespace && ( + <> +
Namespace:
+
{event.objectRef.namespace}
+ + )} + {event.objectRef.apiGroup && ( + <> +
API Group:
+
{event.objectRef.apiGroup}
+ + )} + {event.objectRef.apiVersion && ( + <> +
API Version:
+
{event.objectRef.apiVersion}
+ + )} + {event.objectRef.uid && ( + <> +
UID:
+
{event.objectRef.uid}
+ + )} + {event.objectRef.subresource && ( + <> +
Subresource:
+
{event.objectRef.subresource}
+ + )} +
+
+ )} + + {/* Request Details */} + {(event.userAgent || (event.sourceIPs && event.sourceIPs.length > 0)) && ( +
+

+ Request Details +

+
+ {event.userAgent && ( + <> +
User Agent:
+
{event.userAgent}
+ + )} + {event.sourceIPs && event.sourceIPs.length > 0 && ( + <> +
Source IPs:
+
{event.sourceIPs.join(', ')}
+ + )} +
+
+ )} + + {/* Advanced Details (collapsed) */} + {(event.auditID || event.stage || event.level || (event.annotations && Object.keys(event.annotations).length > 0)) && ( +
+ +

+ + Advanced +

+
+
+
+ {event.auditID && ( + <> +
Audit ID:
+
{event.auditID}
+ + )} + {event.stage && ( + <> +
Stage:
+
{event.stage}
+ + )} + {event.level && ( + <> +
Level:
+
{event.level}
+ + )} + {event.annotations && Object.entries(event.annotations).map(([key, value]) => ( +
+
{key}:
+
{value}
+
+ ))} +
+
+
+ )} + + {/* Raw Objects (collapsed) */} + {(event.requestObject || event.responseObject) ? ( +
+ +

+ + Raw Objects +

+
+
+ {event.requestObject ? ( +
+
Request Object
+
+                  {JSON.stringify(event.requestObject, null, 2)}
+                
+
+ ) : null} + {event.responseObject ? ( +
+
Response Object
+
+                  {JSON.stringify(event.responseObject, null, 2)}
+                
+
+ ) : null} +
+
+ ) : null} +
+ ); +} diff --git a/ui/src/components/AuditLogFeedItem.tsx b/ui/src/components/AuditLogFeedItem.tsx index 5e1baec1..17aa676d 100644 --- a/ui/src/components/AuditLogFeedItem.tsx +++ b/ui/src/components/AuditLogFeedItem.tsx @@ -1,196 +1,196 @@ -import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; -import type { Event } from '../types'; -import { AuditLogExpandedDetails } from './AuditLogExpandedDetails'; -import { cn } from '../lib/utils'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; - -export interface AuditLogFeedItemProps { - /** The audit event to render */ - event: Event; - /** Handler called when the item is clicked */ - onEventClick?: (event: Event) => void; - /** Whether the item is selected */ - isSelected?: boolean; - /** Additional CSS class */ - className?: string; - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Whether this is a newly streamed event */ - isNew?: boolean; - /** Whether the item starts expanded */ - defaultExpanded?: boolean; -} - -/** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * Get Tailwind classes for verb badge - */ -function getVerbBadgeClasses(verb?: string): string { - const baseClasses = 'text-[0.55rem] h-5 px-2 py-1 leading-3'; - const normalized = verb?.toLowerCase(); - - switch (normalized) { - case 'create': - return cn(baseClasses, 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'); - case 'update': - case 'patch': - return cn(baseClasses, 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'); - case 'delete': - return cn(baseClasses, 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'); - default: - return cn(baseClasses, 'bg-muted text-muted-foreground'); - } -} - -/** - * Get response status indicator (✓ or ✗) - */ -function getResponseStatusIndicator(code?: number): { icon: string; className: string } { - if (!code) { - return { icon: '?', className: 'text-muted-foreground' }; - } - - if (code >= 200 && code < 300) { - return { icon: '✓', className: 'text-green-600 dark:text-green-400' }; - } - - return { icon: '✗', className: 'text-red-600 dark:text-red-400' }; -} - -/** - * Build human-readable summary - */ -function buildAuditSummary(event: Event): string { - const username = event.user?.username || 'Unknown user'; - const verb = event.verb || 'performed action'; - const kind = event.objectRef?.resource || 'resource'; - const name = event.objectRef?.name || ''; - const namespace = event.objectRef?.namespace; - - let summary = `${username} ${verb} ${kind}`; - if (name) { - summary += ` ${name}`; - } - if (namespace) { - summary += ` in ${namespace}`; - } - - return summary; -} - -/** - * AuditLogFeedItem renders a single audit log event in the feed - */ -export function AuditLogFeedItem({ - event, - onEventClick, - isSelected = false, - className = '', - compact = false, - isNew = false, - defaultExpanded = false, -}: AuditLogFeedItemProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - - const handleClick = () => { - onEventClick?.(event); - }; - - const toggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded(!isExpanded); - }; - - const timestamp = event.stageTimestamp || event.requestReceivedTimestamp; - const summary = buildAuditSummary(event); - const statusIndicator = getResponseStatusIndicator(event.responseStatus?.code); - - return ( - -
- {/* Main Content */} -
- {/* Single row layout: Summary + Metadata + Timestamp + Expand */} -
- {/* Summary - takes remaining space */} -
- {summary} -
- - {/* Verb badge */} - - {event.verb?.toUpperCase() || 'UNKNOWN'} - - - {/* Response status */} - - {statusIndicator.icon} - {event.responseStatus?.code && ( - {event.responseStatus.code} - )} - - - {/* Timestamp */} - - {formatTimestamp(timestamp)} - - - {/* Expand button */} - -
-
-
- - {/* Expanded Details */} - {isExpanded && } -
- ); -} +import { useState } from 'react'; +import { format, formatDistanceToNow } from 'date-fns'; +import type { Event } from '../types'; +import { AuditLogExpandedDetails } from './AuditLogExpandedDetails'; +import { cn } from '../lib/utils'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Badge } from './ui/badge'; + +export interface AuditLogFeedItemProps { + /** The audit event to render */ + event: Event; + /** Handler called when the item is clicked */ + onEventClick?: (event: Event) => void; + /** Whether the item is selected */ + isSelected?: boolean; + /** Additional CSS class */ + className?: string; + /** Whether to show as compact (for resource detail tabs) */ + compact?: boolean; + /** Whether this is a newly streamed event */ + isNew?: boolean; + /** Whether the item starts expanded */ + defaultExpanded?: boolean; +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + const date = new Date(timestamp); + return formatDistanceToNow(date, { addSuffix: true }); + } catch { + return timestamp; + } +} + +/** + * Format timestamp for tooltip (with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + } catch { + return timestamp; + } +} + +/** + * Get Tailwind classes for verb badge + */ +function getVerbBadgeClasses(verb?: string): string { + const baseClasses = 'text-[0.55rem] h-5 px-2 py-1 leading-3'; + const normalized = verb?.toLowerCase(); + + switch (normalized) { + case 'create': + return cn(baseClasses, 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'); + case 'update': + case 'patch': + return cn(baseClasses, 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'); + case 'delete': + return cn(baseClasses, 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'); + default: + return cn(baseClasses, 'bg-muted text-muted-foreground'); + } +} + +/** + * Get response status indicator (✓ or ✗) + */ +function getResponseStatusIndicator(code?: number): { icon: string; className: string } { + if (!code) { + return { icon: '?', className: 'text-muted-foreground' }; + } + + if (code >= 200 && code < 300) { + return { icon: '✓', className: 'text-green-600 dark:text-green-400' }; + } + + return { icon: '✗', className: 'text-red-600 dark:text-red-400' }; +} + +/** + * Build human-readable summary + */ +function buildAuditSummary(event: Event): string { + const username = event.user?.username || 'Unknown user'; + const verb = event.verb || 'performed action'; + const kind = event.objectRef?.resource || 'resource'; + const name = event.objectRef?.name || ''; + const namespace = event.objectRef?.namespace; + + let summary = `${username} ${verb} ${kind}`; + if (name) { + summary += ` ${name}`; + } + if (namespace) { + summary += ` in ${namespace}`; + } + + return summary; +} + +/** + * AuditLogFeedItem renders a single audit log event in the feed + */ +export function AuditLogFeedItem({ + event, + onEventClick, + isSelected = false, + className = '', + compact = false, + isNew = false, + defaultExpanded = false, +}: AuditLogFeedItemProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + const handleClick = () => { + onEventClick?.(event); + }; + + const toggleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + const timestamp = event.stageTimestamp || event.requestReceivedTimestamp; + const summary = buildAuditSummary(event); + const statusIndicator = getResponseStatusIndicator(event.responseStatus?.code); + + return ( + +
+ {/* Main Content */} +
+ {/* Single row layout: Summary + Metadata + Timestamp + Expand */} +
+ {/* Summary - takes remaining space */} +
+ {summary} +
+ + {/* Verb badge */} + + {event.verb?.toUpperCase() || 'UNKNOWN'} + + + {/* Response status */} + + {statusIndicator.icon} + {event.responseStatus?.code && ( + {event.responseStatus.code} + )} + + + {/* Timestamp */} + + {formatTimestamp(timestamp)} + + + {/* Expand button */} + +
+
+
+ + {/* Expanded Details */} + {isExpanded && } +
+ ); +} diff --git a/ui/src/components/AuditLogFilters.tsx b/ui/src/components/AuditLogFilters.tsx index bebe9115..717628f8 100644 --- a/ui/src/components/AuditLogFilters.tsx +++ b/ui/src/components/AuditLogFilters.tsx @@ -1,549 +1,549 @@ -import { useState, useCallback, useEffect } from 'react'; -import { formatISO, subDays } from 'date-fns'; - -import type { ActivityApiClient } from '../api/client'; -import { useAuditLogFacets, type AuditLogTimeRange } from '../hooks/useAuditLogFacets'; -import { TimeRangeDropdown } from './ui/time-range-dropdown'; -import { FilterChip } from './ui/filter-chip'; -import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { ActionMultiSelect } from './ActionMultiSelect'; -import { UserSelect } from './UserSelect'; - -/** - * Filter state for audit logs - */ -export interface AuditLogFilterState { - /** Filter by verb/action (multi-select) */ - verbs?: string[]; - /** Filter by resource type (multi-select) */ - resourceTypes?: string[]; - /** Filter by namespace (multi-select) */ - namespaces?: string[]; - /** Filter by username (multi-select) */ - usernames?: string[]; - /** Filter by resource name (partial match) */ - resourceName?: string; - /** Custom CEL filter */ - customFilter?: string; -} - -/** - * Time range for audit log queries - */ -export interface TimeRange { - start: string; - end?: string; -} - -export interface AuditLogFiltersProps { - /** API client instance for fetching facets */ - client: ActivityApiClient; - /** Current filter state */ - filters: AuditLogFilterState; - /** Current time range */ - timeRange: TimeRange; - /** Handler called when filters change */ - onFiltersChange: (filters: AuditLogFilterState) => void; - /** Handler called when time range changes */ - onTimeRangeChange: (timeRange: TimeRange) => void; - /** Whether the filters are disabled (e.g., during loading) */ - disabled?: boolean; - /** Additional CSS class */ - className?: string; -} - -/** - * Preset time ranges - */ -const TIME_PRESETS = [ - { key: 'last15min', label: 'Last 15 min' }, - { key: 'last1hour', label: 'Last hour' }, - { key: 'last6hours', label: 'Last 6 hours' }, - { key: 'last24hours', label: 'Last 24 hours' }, - { key: 'last7days', label: 'Last 7 days' }, - { key: 'last30days', label: 'Last 30 days' }, -]; - -/** - * Convert preset key to ISO time range - */ -function presetToTimeRange(presetKey: string): AuditLogTimeRange { - const now = new Date(); - let start: Date; - - switch (presetKey) { - case 'last15min': - start = new Date(now.getTime() - 15 * 60 * 1000); - break; - case 'last1hour': - start = new Date(now.getTime() - 60 * 60 * 1000); - break; - case 'last6hours': - start = new Date(now.getTime() - 6 * 60 * 60 * 1000); - break; - case 'last24hours': - start = new Date(now.getTime() - 24 * 60 * 60 * 1000); - break; - case 'last7days': - start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - case 'last30days': - start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - break; - default: - start = new Date(now.getTime() - 24 * 60 * 60 * 1000); - } - - return { - start: formatISO(start), - end: formatISO(now), - }; -} - -/** - * Filter configuration registry - */ -type FilterId = 'verbs' | 'resourceTypes' | 'namespaces' | 'usernames' | 'resourceName'; - -interface FilterConfig { - id: FilterId; - label: string; - inputMode: 'typeahead' | 'text'; - placeholder?: string; - searchPlaceholder?: string; -} - -const FILTER_CONFIGS: Record = { - verbs: { - id: 'verbs', - label: 'Action', - inputMode: 'typeahead', - searchPlaceholder: 'Search actions...', - }, - resourceTypes: { - id: 'resourceTypes', - label: 'Resource', - inputMode: 'typeahead', - searchPlaceholder: 'Search resources...', - }, - namespaces: { - id: 'namespaces', - label: 'Namespace', - inputMode: 'typeahead', - searchPlaceholder: 'Search namespaces...', - }, - usernames: { - id: 'usernames', - label: 'User', - inputMode: 'typeahead', - searchPlaceholder: 'Search users...', - }, - resourceName: { - id: 'resourceName', - label: 'Name', - inputMode: 'text', - placeholder: 'Enter resource name...', - }, -}; - -/** - * Helper function to convert ISO string to datetime-local format - */ -const formatDatetimeLocal = (isoString: string): string => { - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - - -/** - * Build CEL filter expression from filter state - */ -export function buildAuditLogCEL(filters: AuditLogFilterState): string { - const conditions: string[] = []; - - // Verbs filter (multi-select) - if (filters.verbs && filters.verbs.length > 0) { - if (filters.verbs.length === 1) { - conditions.push(`verb == "${filters.verbs[0]}"`); - } else { - const verbConditions = filters.verbs.map((v) => `verb == "${v}"`); - conditions.push(`(${verbConditions.join(' || ')})`); - } - } - - // Resource types filter (multi-select) - if (filters.resourceTypes && filters.resourceTypes.length > 0) { - if (filters.resourceTypes.length === 1) { - conditions.push(`objectRef.resource == "${filters.resourceTypes[0]}"`); - } else { - const resConditions = filters.resourceTypes.map((r) => `objectRef.resource == "${r}"`); - conditions.push(`(${resConditions.join(' || ')})`); - } - } - - // Namespaces filter (multi-select) - if (filters.namespaces && filters.namespaces.length > 0) { - if (filters.namespaces.length === 1) { - conditions.push(`objectRef.namespace == "${filters.namespaces[0]}"`); - } else { - const nsConditions = filters.namespaces.map((ns) => `objectRef.namespace == "${ns}"`); - conditions.push(`(${nsConditions.join(' || ')})`); - } - } - - // Usernames filter (multi-select) - if (filters.usernames && filters.usernames.length > 0) { - if (filters.usernames.length === 1) { - conditions.push(`user.username == "${filters.usernames[0]}"`); - } else { - const userConditions = filters.usernames.map((u) => `user.username == "${u}"`); - conditions.push(`(${userConditions.join(' || ')})`); - } - } - - // Resource name filter (partial match) - if (filters.resourceName) { - conditions.push(`objectRef.name.contains("${filters.resourceName}")`); - } - - // Custom filter - if (filters.customFilter) { - conditions.push(filters.customFilter); - } - - return conditions.join(' && '); -} - -/** - * AuditLogFilters provides compact filter controls for audit log queries - */ -export function AuditLogFilters({ - client, - filters, - timeRange, - onFiltersChange, - onTimeRangeChange, - disabled = false, - className = '', -}: AuditLogFiltersProps) { - // Convert timeRange to format expected by useAuditLogFacets - const [facetTimeRange, setFacetTimeRange] = useState(() => - presetToTimeRange('last24hours') - ); - - const { verbs, resources, namespaces, usernames, error: facetsError } = useAuditLogFacets( - client, - facetTimeRange - ); - - // Log facets error for debugging - if (facetsError) { - console.error('Failed to load audit log facets:', facetsError); - } - - // Track which filter was just added to auto-open it - const [pendingFilter, setPendingFilter] = useState(null); - - // Track selected preset - const [selectedPreset, setSelectedPreset] = useState('last24hours'); - - // Custom time range state - const [customStart, setCustomStart] = useState(() => - formatDatetimeLocal(formatISO(subDays(new Date(), 1))) - ); - const [customEnd, setCustomEnd] = useState(() => formatDatetimeLocal(formatISO(new Date()))); - - // Handle time range preset selection - const handleTimePresetSelect = useCallback( - (presetKey: string) => { - setSelectedPreset(presetKey); - const range = presetToTimeRange(presetKey); - setFacetTimeRange(range); - onTimeRangeChange({ - start: range.start, - end: range.end, - }); - }, - [onTimeRangeChange] - ); - - // Handle custom time range apply - const handleCustomRangeApply = useCallback( - (start: string, end: string) => { - setSelectedPreset('custom'); - setCustomStart(start); - setCustomEnd(end); - const startIso = new Date(start).toISOString(); - const endIso = new Date(end).toISOString(); - setFacetTimeRange({ start: startIso, end: endIso }); - onTimeRangeChange({ - start: startIso, - end: endIso, - }); - }, - [onTimeRangeChange] - ); - - // Get display label for time range - const getTimeRangeLabel = () => { - const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); - if (preset) return preset.label; - if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { - const start = new Date(timeRange.start); - const end = new Date(timeRange.end); - return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; - } - return 'Select time range'; - }; - - // Determine which filters are currently active (have values) - // Note: We exclude verbs and usernames from filter chips since they're handled by quick filters - const filtersWithValues: FilterId[] = []; - // if (filters.verbs && filters.verbs.length > 0) filtersWithValues.push('verbs'); // Handled by ActionToggle - if (filters.resourceTypes && filters.resourceTypes.length > 0) filtersWithValues.push('resourceTypes'); - if (filters.namespaces && filters.namespaces.length > 0) filtersWithValues.push('namespaces'); - // if (filters.usernames && filters.usernames.length > 0) filtersWithValues.push('usernames'); // Handled by UserSelect - if (filters.resourceName) filtersWithValues.push('resourceName'); - - // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters - const activeFilterIds: FilterId[] = - pendingFilter && !filtersWithValues.includes(pendingFilter) - ? [...filtersWithValues, pendingFilter] - : filtersWithValues; - - // Clear pending filter when filter values change (user selected something) - useEffect(() => { - if (pendingFilter && filtersWithValues.includes(pendingFilter)) { - // Filter now has values, clear pending state - setPendingFilter(null); - } - }, [pendingFilter, filtersWithValues]); - - // Build available filters list - // Note: Action and User are now quick filters, so they're excluded from the dropdown - const availableFilters: FilterOption[] = [ - { id: 'resourceTypes', label: 'Resource' }, - { id: 'namespaces', label: 'Namespace' }, - { id: 'resourceName', label: 'Name' }, - ]; - - // Handle adding a filter - const handleAddFilter = useCallback((filterId: string) => { - setPendingFilter(filterId as FilterId); - }, []); - - // Handle popover close - clear pending filter if no values were selected - const handlePopoverClose = useCallback( - (filterId: FilterId) => { - if (pendingFilter === filterId) { - const hasValues = (() => { - const value = filters[filterId]; - if (filterId === 'resourceName') return !!value; - return Array.isArray(value) && value.length > 0; - })(); - if (!hasValues) { - setPendingFilter(null); - } - } - }, - [pendingFilter, filters] - ); - - // Handle filter value changes - const handleFilterChange = useCallback( - (filterId: FilterId, values: string[]) => { - onFiltersChange({ - ...filters, - [filterId]: values.length > 0 ? values : undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Handle filter clear - const handleFilterClear = useCallback( - (filterId: FilterId) => { - onFiltersChange({ - ...filters, - [filterId]: undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Get options for a specific filter - const getFilterOptions = (filterId: FilterId) => { - switch (filterId) { - case 'verbs': - return verbs - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'resourceTypes': - return resources - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'namespaces': - return namespaces - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'usernames': - return usernames - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - default: - return []; - } - }; - - // Get values for a specific filter - const getFilterValues = (filterId: FilterId): string[] => { - const value = filters[filterId]; - if (filterId === 'resourceName') { - return value ? [value as string] : []; - } - return (value as string[] | undefined) || []; - }; - - // Handle action multi-select change - const handleActionChange = useCallback( - (selectedVerbs: string[]) => { - onFiltersChange({ - ...filters, - verbs: selectedVerbs.length > 0 ? selectedVerbs : undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Get current action values for multi-select - const getActionValues = (): string[] => { - return filters.verbs || []; - }; - - // Prepare action options from facets - const actionOptions = verbs - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value.charAt(0).toUpperCase() + facet.value.slice(1), // Capitalize first letter - count: facet.count, - })); - - // Handle user select change - const handleUserChange = useCallback( - (username?: string) => { - onFiltersChange({ - ...filters, - usernames: username ? [username] : undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Get current user value for select (single selection for quick filter) - const getCurrentUser = (): string | undefined => { - return filters.usernames && filters.usernames.length === 1 - ? filters.usernames[0] - : undefined; - }; - - // Prepare user options for select - const userOptions = usernames - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - - return ( -
-
- {/* Action Multi-Select */} - - - {/* User Select */} - - - {/* Active Filter Chips */} - {activeFilterIds.map((filterId) => { - const config = FILTER_CONFIGS[filterId]; - return ( - handleFilterChange(filterId, values)} - onClear={() => handleFilterClear(filterId)} - onPopoverClose={() => handlePopoverClose(filterId)} - inputMode={config.inputMode} - placeholder={config.placeholder} - searchPlaceholder={config.searchPlaceholder} - autoOpen={pendingFilter === filterId} - disabled={disabled} - /> - ); - })} - - {/* Add Filter Dropdown */} - 0} - disabled={disabled} - /> - - {/* Spacer */} -
- - {/* Time Range Dropdown */} - -
-
- ); -} +import { useState, useCallback, useEffect } from 'react'; +import { formatISO, subDays } from 'date-fns'; + +import type { ActivityApiClient } from '../api/client'; +import { useAuditLogFacets, type AuditLogTimeRange } from '../hooks/useAuditLogFacets'; +import { TimeRangeDropdown } from './ui/time-range-dropdown'; +import { FilterChip } from './ui/filter-chip'; +import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; +import { ActionMultiSelect } from './ActionMultiSelect'; +import { UserSelect } from './UserSelect'; + +/** + * Filter state for audit logs + */ +export interface AuditLogFilterState { + /** Filter by verb/action (multi-select) */ + verbs?: string[]; + /** Filter by resource type (multi-select) */ + resourceTypes?: string[]; + /** Filter by namespace (multi-select) */ + namespaces?: string[]; + /** Filter by username (multi-select) */ + usernames?: string[]; + /** Filter by resource name (partial match) */ + resourceName?: string; + /** Custom CEL filter */ + customFilter?: string; +} + +/** + * Time range for audit log queries + */ +export interface TimeRange { + start: string; + end?: string; +} + +export interface AuditLogFiltersProps { + /** API client instance for fetching facets */ + client: ActivityApiClient; + /** Current filter state */ + filters: AuditLogFilterState; + /** Current time range */ + timeRange: TimeRange; + /** Handler called when filters change */ + onFiltersChange: (filters: AuditLogFilterState) => void; + /** Handler called when time range changes */ + onTimeRangeChange: (timeRange: TimeRange) => void; + /** Whether the filters are disabled (e.g., during loading) */ + disabled?: boolean; + /** Additional CSS class */ + className?: string; +} + +/** + * Preset time ranges + */ +const TIME_PRESETS = [ + { key: 'last15min', label: 'Last 15 min' }, + { key: 'last1hour', label: 'Last hour' }, + { key: 'last6hours', label: 'Last 6 hours' }, + { key: 'last24hours', label: 'Last 24 hours' }, + { key: 'last7days', label: 'Last 7 days' }, + { key: 'last30days', label: 'Last 30 days' }, +]; + +/** + * Convert preset key to ISO time range + */ +function presetToTimeRange(presetKey: string): AuditLogTimeRange { + const now = new Date(); + let start: Date; + + switch (presetKey) { + case 'last15min': + start = new Date(now.getTime() - 15 * 60 * 1000); + break; + case 'last1hour': + start = new Date(now.getTime() - 60 * 60 * 1000); + break; + case 'last6hours': + start = new Date(now.getTime() - 6 * 60 * 60 * 1000); + break; + case 'last24hours': + start = new Date(now.getTime() - 24 * 60 * 60 * 1000); + break; + case 'last7days': + start = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + break; + case 'last30days': + start = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + break; + default: + start = new Date(now.getTime() - 24 * 60 * 60 * 1000); + } + + return { + start: formatISO(start), + end: formatISO(now), + }; +} + +/** + * Filter configuration registry + */ +type FilterId = 'verbs' | 'resourceTypes' | 'namespaces' | 'usernames' | 'resourceName'; + +interface FilterConfig { + id: FilterId; + label: string; + inputMode: 'typeahead' | 'text'; + placeholder?: string; + searchPlaceholder?: string; +} + +const FILTER_CONFIGS: Record = { + verbs: { + id: 'verbs', + label: 'Action', + inputMode: 'typeahead', + searchPlaceholder: 'Search actions...', + }, + resourceTypes: { + id: 'resourceTypes', + label: 'Resource', + inputMode: 'typeahead', + searchPlaceholder: 'Search resources...', + }, + namespaces: { + id: 'namespaces', + label: 'Namespace', + inputMode: 'typeahead', + searchPlaceholder: 'Search namespaces...', + }, + usernames: { + id: 'usernames', + label: 'User', + inputMode: 'typeahead', + searchPlaceholder: 'Search users...', + }, + resourceName: { + id: 'resourceName', + label: 'Name', + inputMode: 'text', + placeholder: 'Enter resource name...', + }, +}; + +/** + * Helper function to convert ISO string to datetime-local format + */ +const formatDatetimeLocal = (isoString: string): string => { + const date = new Date(isoString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + + +/** + * Build CEL filter expression from filter state + */ +export function buildAuditLogCEL(filters: AuditLogFilterState): string { + const conditions: string[] = []; + + // Verbs filter (multi-select) + if (filters.verbs && filters.verbs.length > 0) { + if (filters.verbs.length === 1) { + conditions.push(`verb == "${filters.verbs[0]}"`); + } else { + const verbConditions = filters.verbs.map((v) => `verb == "${v}"`); + conditions.push(`(${verbConditions.join(' || ')})`); + } + } + + // Resource types filter (multi-select) + if (filters.resourceTypes && filters.resourceTypes.length > 0) { + if (filters.resourceTypes.length === 1) { + conditions.push(`objectRef.resource == "${filters.resourceTypes[0]}"`); + } else { + const resConditions = filters.resourceTypes.map((r) => `objectRef.resource == "${r}"`); + conditions.push(`(${resConditions.join(' || ')})`); + } + } + + // Namespaces filter (multi-select) + if (filters.namespaces && filters.namespaces.length > 0) { + if (filters.namespaces.length === 1) { + conditions.push(`objectRef.namespace == "${filters.namespaces[0]}"`); + } else { + const nsConditions = filters.namespaces.map((ns) => `objectRef.namespace == "${ns}"`); + conditions.push(`(${nsConditions.join(' || ')})`); + } + } + + // Usernames filter (multi-select) + if (filters.usernames && filters.usernames.length > 0) { + if (filters.usernames.length === 1) { + conditions.push(`user.username == "${filters.usernames[0]}"`); + } else { + const userConditions = filters.usernames.map((u) => `user.username == "${u}"`); + conditions.push(`(${userConditions.join(' || ')})`); + } + } + + // Resource name filter (partial match) + if (filters.resourceName) { + conditions.push(`objectRef.name.contains("${filters.resourceName}")`); + } + + // Custom filter + if (filters.customFilter) { + conditions.push(filters.customFilter); + } + + return conditions.join(' && '); +} + +/** + * AuditLogFilters provides compact filter controls for audit log queries + */ +export function AuditLogFilters({ + client, + filters, + timeRange, + onFiltersChange, + onTimeRangeChange, + disabled = false, + className = '', +}: AuditLogFiltersProps) { + // Convert timeRange to format expected by useAuditLogFacets + const [facetTimeRange, setFacetTimeRange] = useState(() => + presetToTimeRange('last24hours') + ); + + const { verbs, resources, namespaces, usernames, error: facetsError } = useAuditLogFacets( + client, + facetTimeRange + ); + + // Log facets error for debugging + if (facetsError) { + console.error('Failed to load audit log facets:', facetsError); + } + + // Track which filter was just added to auto-open it + const [pendingFilter, setPendingFilter] = useState(null); + + // Track selected preset + const [selectedPreset, setSelectedPreset] = useState('last24hours'); + + // Custom time range state + const [customStart, setCustomStart] = useState(() => + formatDatetimeLocal(formatISO(subDays(new Date(), 1))) + ); + const [customEnd, setCustomEnd] = useState(() => formatDatetimeLocal(formatISO(new Date()))); + + // Handle time range preset selection + const handleTimePresetSelect = useCallback( + (presetKey: string) => { + setSelectedPreset(presetKey); + const range = presetToTimeRange(presetKey); + setFacetTimeRange(range); + onTimeRangeChange({ + start: range.start, + end: range.end, + }); + }, + [onTimeRangeChange] + ); + + // Handle custom time range apply + const handleCustomRangeApply = useCallback( + (start: string, end: string) => { + setSelectedPreset('custom'); + setCustomStart(start); + setCustomEnd(end); + const startIso = new Date(start).toISOString(); + const endIso = new Date(end).toISOString(); + setFacetTimeRange({ start: startIso, end: endIso }); + onTimeRangeChange({ + start: startIso, + end: endIso, + }); + }, + [onTimeRangeChange] + ); + + // Get display label for time range + const getTimeRangeLabel = () => { + const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); + if (preset) return preset.label; + if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { + const start = new Date(timeRange.start); + const end = new Date(timeRange.end); + return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; + } + return 'Select time range'; + }; + + // Determine which filters are currently active (have values) + // Note: We exclude verbs and usernames from filter chips since they're handled by quick filters + const filtersWithValues: FilterId[] = []; + // if (filters.verbs && filters.verbs.length > 0) filtersWithValues.push('verbs'); // Handled by ActionToggle + if (filters.resourceTypes && filters.resourceTypes.length > 0) filtersWithValues.push('resourceTypes'); + if (filters.namespaces && filters.namespaces.length > 0) filtersWithValues.push('namespaces'); + // if (filters.usernames && filters.usernames.length > 0) filtersWithValues.push('usernames'); // Handled by UserSelect + if (filters.resourceName) filtersWithValues.push('resourceName'); + + // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters + const activeFilterIds: FilterId[] = + pendingFilter && !filtersWithValues.includes(pendingFilter) + ? [...filtersWithValues, pendingFilter] + : filtersWithValues; + + // Clear pending filter when filter values change (user selected something) + useEffect(() => { + if (pendingFilter && filtersWithValues.includes(pendingFilter)) { + // Filter now has values, clear pending state + setPendingFilter(null); + } + }, [pendingFilter, filtersWithValues]); + + // Build available filters list + // Note: Action and User are now quick filters, so they're excluded from the dropdown + const availableFilters: FilterOption[] = [ + { id: 'resourceTypes', label: 'Resource' }, + { id: 'namespaces', label: 'Namespace' }, + { id: 'resourceName', label: 'Name' }, + ]; + + // Handle adding a filter + const handleAddFilter = useCallback((filterId: string) => { + setPendingFilter(filterId as FilterId); + }, []); + + // Handle popover close - clear pending filter if no values were selected + const handlePopoverClose = useCallback( + (filterId: FilterId) => { + if (pendingFilter === filterId) { + const hasValues = (() => { + const value = filters[filterId]; + if (filterId === 'resourceName') return !!value; + return Array.isArray(value) && value.length > 0; + })(); + if (!hasValues) { + setPendingFilter(null); + } + } + }, + [pendingFilter, filters] + ); + + // Handle filter value changes + const handleFilterChange = useCallback( + (filterId: FilterId, values: string[]) => { + onFiltersChange({ + ...filters, + [filterId]: values.length > 0 ? values : undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Handle filter clear + const handleFilterClear = useCallback( + (filterId: FilterId) => { + onFiltersChange({ + ...filters, + [filterId]: undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Get options for a specific filter + const getFilterOptions = (filterId: FilterId) => { + switch (filterId) { + case 'verbs': + return verbs + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'resourceTypes': + return resources + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'namespaces': + return namespaces + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'usernames': + return usernames + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + default: + return []; + } + }; + + // Get values for a specific filter + const getFilterValues = (filterId: FilterId): string[] => { + const value = filters[filterId]; + if (filterId === 'resourceName') { + return value ? [value as string] : []; + } + return (value as string[] | undefined) || []; + }; + + // Handle action multi-select change + const handleActionChange = useCallback( + (selectedVerbs: string[]) => { + onFiltersChange({ + ...filters, + verbs: selectedVerbs.length > 0 ? selectedVerbs : undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Get current action values for multi-select + const getActionValues = (): string[] => { + return filters.verbs || []; + }; + + // Prepare action options from facets + const actionOptions = verbs + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value.charAt(0).toUpperCase() + facet.value.slice(1), // Capitalize first letter + count: facet.count, + })); + + // Handle user select change + const handleUserChange = useCallback( + (username?: string) => { + onFiltersChange({ + ...filters, + usernames: username ? [username] : undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Get current user value for select (single selection for quick filter) + const getCurrentUser = (): string | undefined => { + return filters.usernames && filters.usernames.length === 1 + ? filters.usernames[0] + : undefined; + }; + + // Prepare user options for select + const userOptions = usernames + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + + return ( +
+
+ {/* Action Multi-Select */} + + + {/* User Select */} + + + {/* Active Filter Chips */} + {activeFilterIds.map((filterId) => { + const config = FILTER_CONFIGS[filterId]; + return ( + handleFilterChange(filterId, values)} + onClear={() => handleFilterClear(filterId)} + onPopoverClose={() => handlePopoverClose(filterId)} + inputMode={config.inputMode} + placeholder={config.placeholder} + searchPlaceholder={config.searchPlaceholder} + autoOpen={pendingFilter === filterId} + disabled={disabled} + /> + ); + })} + + {/* Add Filter Dropdown */} + 0} + disabled={disabled} + /> + + {/* Spacer */} +
+ + {/* Time Range Dropdown */} + +
+
+ ); +} diff --git a/ui/src/components/AuditLogQueryComponent.tsx b/ui/src/components/AuditLogQueryComponent.tsx index 82574b7e..3e548280 100644 --- a/ui/src/components/AuditLogQueryComponent.tsx +++ b/ui/src/components/AuditLogQueryComponent.tsx @@ -1,245 +1,245 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { formatISO, subDays } from 'date-fns'; -import { AuditLogFilters, buildAuditLogCEL, type AuditLogFilterState, type TimeRange } from './AuditLogFilters'; -import { AuditLogFeedItem } from './AuditLogFeedItem'; -import { useAuditLogQuery } from '../hooks/useAuditLogQuery'; -import type { AuditLogQuerySpec, Event } from '../types'; -import type { ActivityApiClient } from '../api/client'; -import type { ErrorFormatter } from '../types/activity'; -import { Card } from './ui/card'; -import { ApiErrorAlert } from './ApiErrorAlert'; - -// Debounce delay for filter changes (ms) -const FILTER_DEBOUNCE_MS = 300; - -// Default page size for infinite scroll -const DEFAULT_PAGE_SIZE = 100; - -export interface AuditLogQueryComponentProps { - client: ActivityApiClient; - className?: string; - onEventSelect?: (event: Event) => void; - initialFilters?: AuditLogFilterState; - initialTimeRange?: TimeRange; - /** Custom error formatter for customizing error messages */ - errorFormatter?: ErrorFormatter; -} - -/** - * Complete audit log query component with filter builder and results viewer - */ -export function AuditLogQueryComponent({ - client, - className = '', - onEventSelect, - initialFilters = {}, - initialTimeRange = { - start: formatISO(subDays(new Date(), 1)), - end: formatISO(new Date()), - }, - errorFormatter, -}: AuditLogQueryComponentProps) { - const [filters, setFilters] = useState(initialFilters); - const [timeRange, setTimeRange] = useState(initialTimeRange); - - const { events, isLoading, error, hasMore, executeQuery, loadMore } = - useAuditLogQuery({ client }); - - const loadMoreTriggerRef = useRef(null); - const scrollContainerRef = useRef(null); - // Store the latest loadMore function in a ref to avoid observer re-subscription - const loadMoreRef = useRef(loadMore); - const filterDebounceRef = useRef | null>(null); - const hasInitialLoadRef = useRef(false); - - // Build query spec from current filter state - const buildQuerySpec = useCallback((): AuditLogQuerySpec => { - const spec: AuditLogQuerySpec = { - filter: buildAuditLogCEL(filters) || '', - startTime: timeRange.start, - endTime: timeRange.end, - limit: DEFAULT_PAGE_SIZE, - }; - return spec; - }, [filters, timeRange]); - - // Execute query with current filters - const refresh = useCallback(async () => { - const spec = buildQuerySpec(); - await executeQuery(spec); - hasInitialLoadRef.current = true; - }, [buildQuerySpec, executeQuery]); - - // Handle filter changes with debounced auto-refresh - const handleFiltersChange = useCallback( - (newFilters: AuditLogFilterState) => { - setFilters(newFilters); - - // Cancel any pending debounced refresh - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } - - // Debounce the refresh to avoid excessive API calls - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - }, FILTER_DEBOUNCE_MS); - }, - [] - ); - - // Handle time range changes with debounced auto-refresh - const handleTimeRangeChange = useCallback( - (newTimeRange: TimeRange) => { - setTimeRange(newTimeRange); - - // Cancel any pending debounced refresh - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } - - // Debounce the refresh - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - }, FILTER_DEBOUNCE_MS); - }, - [] - ); - - // Auto-refresh when filters or time range change (debounced) - useEffect(() => { - // Skip the initial render - we'll handle that separately - if (!hasInitialLoadRef.current) { - return; - } - - // Cancel any pending refresh - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } - - // Debounce the refresh - filterDebounceRef.current = setTimeout(() => { - filterDebounceRef.current = null; - refresh(); - }, FILTER_DEBOUNCE_MS); - - return () => { - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - filterDebounceRef.current = null; - } - }; - }, [filters, timeRange, refresh]); - - // Auto-execute on mount - useEffect(() => { - refresh(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Update the ref whenever loadMore changes - useEffect(() => { - loadMoreRef.current = loadMore; - }, [loadMore]); - - // Infinite scroll using Intersection Observer - useEffect(() => { - if (!loadMoreTriggerRef.current) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { - console.log('[AuditLogQueryComponent] Intersection triggered, loading more...'); - // Call through the ref to always use the latest function - loadMoreRef.current(); - } - }, - { - root: scrollContainerRef.current, - rootMargin: '200px', - threshold: 0, - } - ); - - observer.observe(loadMoreTriggerRef.current); - - return () => { - observer.disconnect(); - }; - }, [hasMore, isLoading]); - - // Cleanup on unmount - useEffect(() => { - return () => { - if (filterDebounceRef.current) { - clearTimeout(filterDebounceRef.current); - } - }; - }, []); - - return ( - - {/* Filters */} - - - {/* Error Display */} - - - {/* Event List with Infinite Scroll */} -
- {/* Loading State (initial load) */} - {isLoading && events.length === 0 && ( -
-
- Searching audit logs... -
- )} - - {/* Empty State */} - {!isLoading && events.length === 0 && !error && ( -
-

No audit events found

-

- Try adjusting your filters or time range -

-
- )} - - {/* Event List */} - {events.map((event, index) => ( - - ))} - - {/* Load More Trigger for Infinite Scroll */} - {hasMore &&
} - - {/* Loading Indicator (pagination) */} - {isLoading && events.length > 0 && ( -
-
- Loading more events... -
- )} - - {/* End of Results */} - {!hasMore && events.length > 0 && !isLoading && ( -
- End of results -
- )} -
- - ); -} +import { useState, useEffect, useRef, useCallback } from 'react'; +import { formatISO, subDays } from 'date-fns'; +import { AuditLogFilters, buildAuditLogCEL, type AuditLogFilterState, type TimeRange } from './AuditLogFilters'; +import { AuditLogFeedItem } from './AuditLogFeedItem'; +import { useAuditLogQuery } from '../hooks/useAuditLogQuery'; +import type { AuditLogQuerySpec, Event } from '../types'; +import type { ActivityApiClient } from '../api/client'; +import type { ErrorFormatter } from '../types/activity'; +import { Card } from './ui/card'; +import { ApiErrorAlert } from './ApiErrorAlert'; + +// Debounce delay for filter changes (ms) +const FILTER_DEBOUNCE_MS = 300; + +// Default page size for infinite scroll +const DEFAULT_PAGE_SIZE = 100; + +export interface AuditLogQueryComponentProps { + client: ActivityApiClient; + className?: string; + onEventSelect?: (event: Event) => void; + initialFilters?: AuditLogFilterState; + initialTimeRange?: TimeRange; + /** Custom error formatter for customizing error messages */ + errorFormatter?: ErrorFormatter; +} + +/** + * Complete audit log query component with filter builder and results viewer + */ +export function AuditLogQueryComponent({ + client, + className = '', + onEventSelect, + initialFilters = {}, + initialTimeRange = { + start: formatISO(subDays(new Date(), 1)), + end: formatISO(new Date()), + }, + errorFormatter, +}: AuditLogQueryComponentProps) { + const [filters, setFilters] = useState(initialFilters); + const [timeRange, setTimeRange] = useState(initialTimeRange); + + const { events, isLoading, error, hasMore, executeQuery, loadMore } = + useAuditLogQuery({ client }); + + const loadMoreTriggerRef = useRef(null); + const scrollContainerRef = useRef(null); + // Store the latest loadMore function in a ref to avoid observer re-subscription + const loadMoreRef = useRef(loadMore); + const filterDebounceRef = useRef | null>(null); + const hasInitialLoadRef = useRef(false); + + // Build query spec from current filter state + const buildQuerySpec = useCallback((): AuditLogQuerySpec => { + const spec: AuditLogQuerySpec = { + filter: buildAuditLogCEL(filters) || '', + startTime: timeRange.start, + endTime: timeRange.end, + limit: DEFAULT_PAGE_SIZE, + }; + return spec; + }, [filters, timeRange]); + + // Execute query with current filters + const refresh = useCallback(async () => { + const spec = buildQuerySpec(); + await executeQuery(spec); + hasInitialLoadRef.current = true; + }, [buildQuerySpec, executeQuery]); + + // Handle filter changes with debounced auto-refresh + const handleFiltersChange = useCallback( + (newFilters: AuditLogFilterState) => { + setFilters(newFilters); + + // Cancel any pending debounced refresh + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } + + // Debounce the refresh to avoid excessive API calls + filterDebounceRef.current = setTimeout(() => { + filterDebounceRef.current = null; + }, FILTER_DEBOUNCE_MS); + }, + [] + ); + + // Handle time range changes with debounced auto-refresh + const handleTimeRangeChange = useCallback( + (newTimeRange: TimeRange) => { + setTimeRange(newTimeRange); + + // Cancel any pending debounced refresh + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } + + // Debounce the refresh + filterDebounceRef.current = setTimeout(() => { + filterDebounceRef.current = null; + }, FILTER_DEBOUNCE_MS); + }, + [] + ); + + // Auto-refresh when filters or time range change (debounced) + useEffect(() => { + // Skip the initial render - we'll handle that separately + if (!hasInitialLoadRef.current) { + return; + } + + // Cancel any pending refresh + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } + + // Debounce the refresh + filterDebounceRef.current = setTimeout(() => { + filterDebounceRef.current = null; + refresh(); + }, FILTER_DEBOUNCE_MS); + + return () => { + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + filterDebounceRef.current = null; + } + }; + }, [filters, timeRange, refresh]); + + // Auto-execute on mount + useEffect(() => { + refresh(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update the ref whenever loadMore changes + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + // Infinite scroll using Intersection Observer + useEffect(() => { + if (!loadMoreTriggerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && hasMore && !isLoading) { + console.log('[AuditLogQueryComponent] Intersection triggered, loading more...'); + // Call through the ref to always use the latest function + loadMoreRef.current(); + } + }, + { + root: scrollContainerRef.current, + rootMargin: '200px', + threshold: 0, + } + ); + + observer.observe(loadMoreTriggerRef.current); + + return () => { + observer.disconnect(); + }; + }, [hasMore, isLoading]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (filterDebounceRef.current) { + clearTimeout(filterDebounceRef.current); + } + }; + }, []); + + return ( + + {/* Filters */} + + + {/* Error Display */} + + + {/* Event List with Infinite Scroll */} +
+ {/* Loading State (initial load) */} + {isLoading && events.length === 0 && ( +
+
+ Searching audit logs... +
+ )} + + {/* Empty State */} + {!isLoading && events.length === 0 && !error && ( +
+

No audit events found

+

+ Try adjusting your filters or time range +

+
+ )} + + {/* Event List */} + {events.map((event, index) => ( + + ))} + + {/* Load More Trigger for Infinite Scroll */} + {hasMore &&
} + + {/* Loading Indicator (pagination) */} + {isLoading && events.length > 0 && ( +
+
+ Loading more events... +
+ )} + + {/* End of Results */} + {!hasMore && events.length > 0 && !isLoading && ( +
+ End of results +
+ )} +
+ + ); +} diff --git a/ui/src/components/ChangeSourceToggle.tsx b/ui/src/components/ChangeSourceToggle.tsx index 909f136c..0fab18a5 100644 --- a/ui/src/components/ChangeSourceToggle.tsx +++ b/ui/src/components/ChangeSourceToggle.tsx @@ -1,76 +1,76 @@ -import type { ChangeSource } from '../types/activity'; -import { Button } from './ui/button'; -import { cn } from '../lib/utils'; - -export type ChangeSourceOption = ChangeSource | 'all'; - -export interface ChangeSourceToggleProps { - /** Current selected value */ - value: ChangeSourceOption; - /** Handler called when selection changes */ - onChange: (value: ChangeSourceOption) => void; - /** Additional CSS class */ - className?: string; - /** Whether the toggle is disabled */ - disabled?: boolean; -} - -/** - * Options for the change source toggle - */ -const OPTIONS: { value: ChangeSourceOption; label: string; description: string }[] = [ - { - value: 'all', - label: 'All', - description: 'Show all activities', - }, - { - value: 'human', - label: 'Human', - description: 'Show only human-initiated activities', - }, - { - value: 'system', - label: 'System', - description: 'Show only system-initiated activities', - }, -]; - -/** - * ChangeSourceToggle provides a segmented control for filtering by change source - */ -export function ChangeSourceToggle({ - value, - onChange, - className = '', - disabled = false, -}: ChangeSourceToggleProps) { - return ( -
- {OPTIONS.map((option, index) => ( - - ))} -
- ); -} +import type { ChangeSource } from '../types/activity'; +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; + +export type ChangeSourceOption = ChangeSource | 'all'; + +export interface ChangeSourceToggleProps { + /** Current selected value */ + value: ChangeSourceOption; + /** Handler called when selection changes */ + onChange: (value: ChangeSourceOption) => void; + /** Additional CSS class */ + className?: string; + /** Whether the toggle is disabled */ + disabled?: boolean; +} + +/** + * Options for the change source toggle + */ +const OPTIONS: { value: ChangeSourceOption; label: string; description: string }[] = [ + { + value: 'all', + label: 'All', + description: 'Show all activities', + }, + { + value: 'human', + label: 'Human', + description: 'Show only human-initiated activities', + }, + { + value: 'system', + label: 'System', + description: 'Show only system-initiated activities', + }, +]; + +/** + * ChangeSourceToggle provides a segmented control for filtering by change source + */ +export function ChangeSourceToggle({ + value, + onChange, + className = '', + disabled = false, +}: ChangeSourceToggleProps) { + return ( +
+ {OPTIONS.map((option, index) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/DateTimeRangePicker.tsx b/ui/src/components/DateTimeRangePicker.tsx index 10392f96..ea6eecef 100644 --- a/ui/src/components/DateTimeRangePicker.tsx +++ b/ui/src/components/DateTimeRangePicker.tsx @@ -1,233 +1,233 @@ -import { useState, useEffect } from 'react'; -import { - subMinutes, - subHours, - subDays, - subWeeks, - startOfDay, - endOfDay, - formatISO -} from 'date-fns'; -import { Button } from './ui/button'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Card, CardContent } from './ui/card'; - -export interface DateTimeRange { - start: string; // ISO 8601 timestamp - end: string; // ISO 8601 timestamp -} - -export interface DateTimeRangePickerProps { - onChange: (range: DateTimeRange) => void; - initialRange?: DateTimeRange; - className?: string; -} - -type PresetKey = - | 'last15min' - | 'last1hour' - | 'last6hours' - | 'last24hours' - | 'last7days' - | 'last30days' - | 'today' - | 'custom'; - -interface TimeRangePreset { - label: string; - getValue: () => DateTimeRange; -} - -const PRESETS: Record = { - last15min: { - label: 'Last 15 minutes', - getValue: () => ({ - start: formatISO(subMinutes(new Date(), 15)), - end: formatISO(new Date()), - }), - }, - last1hour: { - label: 'Last 1 hour', - getValue: () => ({ - start: formatISO(subHours(new Date(), 1)), - end: formatISO(new Date()), - }), - }, - last6hours: { - label: 'Last 6 hours', - getValue: () => ({ - start: formatISO(subHours(new Date(), 6)), - end: formatISO(new Date()), - }), - }, - last24hours: { - label: 'Last 24 hours', - getValue: () => ({ - start: formatISO(subHours(new Date(), 24)), - end: formatISO(new Date()), - }), - }, - last7days: { - label: 'Last 7 days', - getValue: () => ({ - start: formatISO(subDays(new Date(), 7)), - end: formatISO(new Date()), - }), - }, - last30days: { - label: 'Last 30 days', - getValue: () => ({ - start: formatISO(subDays(new Date(), 30)), - end: formatISO(new Date()), - }), - }, - today: { - label: 'Today', - getValue: () => ({ - start: formatISO(startOfDay(new Date())), - end: formatISO(endOfDay(new Date())), - }), - }, - custom: { - label: 'Custom range', - getValue: () => ({ - start: formatISO(subHours(new Date(), 1)), - end: formatISO(new Date()), - }), - }, -}; - -// Helper function to convert ISO string to datetime-local format -const formatDatetimeLocal = (isoString: string): string => { - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -/** - * DateTimeRangePicker component for selecting time ranges for audit log queries. - * Supports both preset ranges (last 7 days, last 24 hours, etc.) and custom date/time selection. - */ -export function DateTimeRangePicker({ - onChange, - initialRange, - className = '', -}: DateTimeRangePickerProps) { - const [selectedPreset, setSelectedPreset] = useState('last24hours'); - const [customStart, setCustomStart] = useState(''); - const [customEnd, setCustomEnd] = useState(''); - const [isCustom, setIsCustom] = useState(false); - - // Initialize with initial range or default preset - useEffect(() => { - if (initialRange) { - setCustomStart(formatDatetimeLocal(initialRange.start)); - setCustomEnd(formatDatetimeLocal(initialRange.end)); - setIsCustom(true); - setSelectedPreset('custom'); - } else { - // Auto-apply the default preset on mount - const range = PRESETS['last24hours'].getValue(); - onChange(range); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only run on mount - - const handlePresetChange = (preset: PresetKey) => { - setSelectedPreset(preset); - - if (preset === 'custom') { - setIsCustom(true); - // If switching to custom, initialize with current values or defaults - if (!customStart || !customEnd) { - const defaultRange = PRESETS.last24hours.getValue(); - setCustomStart(formatDatetimeLocal(defaultRange.start)); - setCustomEnd(formatDatetimeLocal(defaultRange.end)); - } - } else { - setIsCustom(false); - const range = PRESETS[preset].getValue(); - onChange(range); - } - }; - - const handleCustomApply = () => { - if (customStart && customEnd) { - const range: DateTimeRange = { - start: new Date(customStart).toISOString(), - end: new Date(customEnd).toISOString(), - }; - onChange(range); - } - }; - - const handleCustomStartChange = (value: string) => { - setCustomStart(value); - }; - - const handleCustomEndChange = (value: string) => { - setCustomEnd(value); - }; - - return ( -
-
- {(Object.keys(PRESETS) as PresetKey[]).map((key) => ( - - ))} -
- - {isCustom && ( - - -
- - handleCustomStartChange(e.target.value)} - /> -
- -
- - handleCustomEndChange(e.target.value)} - /> -
- - -
-
- )} -
- ); -} +import { useState, useEffect } from 'react'; +import { + subMinutes, + subHours, + subDays, + subWeeks, + startOfDay, + endOfDay, + formatISO +} from 'date-fns'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Card, CardContent } from './ui/card'; + +export interface DateTimeRange { + start: string; // ISO 8601 timestamp + end: string; // ISO 8601 timestamp +} + +export interface DateTimeRangePickerProps { + onChange: (range: DateTimeRange) => void; + initialRange?: DateTimeRange; + className?: string; +} + +type PresetKey = + | 'last15min' + | 'last1hour' + | 'last6hours' + | 'last24hours' + | 'last7days' + | 'last30days' + | 'today' + | 'custom'; + +interface TimeRangePreset { + label: string; + getValue: () => DateTimeRange; +} + +const PRESETS: Record = { + last15min: { + label: 'Last 15 minutes', + getValue: () => ({ + start: formatISO(subMinutes(new Date(), 15)), + end: formatISO(new Date()), + }), + }, + last1hour: { + label: 'Last 1 hour', + getValue: () => ({ + start: formatISO(subHours(new Date(), 1)), + end: formatISO(new Date()), + }), + }, + last6hours: { + label: 'Last 6 hours', + getValue: () => ({ + start: formatISO(subHours(new Date(), 6)), + end: formatISO(new Date()), + }), + }, + last24hours: { + label: 'Last 24 hours', + getValue: () => ({ + start: formatISO(subHours(new Date(), 24)), + end: formatISO(new Date()), + }), + }, + last7days: { + label: 'Last 7 days', + getValue: () => ({ + start: formatISO(subDays(new Date(), 7)), + end: formatISO(new Date()), + }), + }, + last30days: { + label: 'Last 30 days', + getValue: () => ({ + start: formatISO(subDays(new Date(), 30)), + end: formatISO(new Date()), + }), + }, + today: { + label: 'Today', + getValue: () => ({ + start: formatISO(startOfDay(new Date())), + end: formatISO(endOfDay(new Date())), + }), + }, + custom: { + label: 'Custom range', + getValue: () => ({ + start: formatISO(subHours(new Date(), 1)), + end: formatISO(new Date()), + }), + }, +}; + +// Helper function to convert ISO string to datetime-local format +const formatDatetimeLocal = (isoString: string): string => { + const date = new Date(isoString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +/** + * DateTimeRangePicker component for selecting time ranges for audit log queries. + * Supports both preset ranges (last 7 days, last 24 hours, etc.) and custom date/time selection. + */ +export function DateTimeRangePicker({ + onChange, + initialRange, + className = '', +}: DateTimeRangePickerProps) { + const [selectedPreset, setSelectedPreset] = useState('last24hours'); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); + const [isCustom, setIsCustom] = useState(false); + + // Initialize with initial range or default preset + useEffect(() => { + if (initialRange) { + setCustomStart(formatDatetimeLocal(initialRange.start)); + setCustomEnd(formatDatetimeLocal(initialRange.end)); + setIsCustom(true); + setSelectedPreset('custom'); + } else { + // Auto-apply the default preset on mount + const range = PRESETS['last24hours'].getValue(); + onChange(range); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Only run on mount + + const handlePresetChange = (preset: PresetKey) => { + setSelectedPreset(preset); + + if (preset === 'custom') { + setIsCustom(true); + // If switching to custom, initialize with current values or defaults + if (!customStart || !customEnd) { + const defaultRange = PRESETS.last24hours.getValue(); + setCustomStart(formatDatetimeLocal(defaultRange.start)); + setCustomEnd(formatDatetimeLocal(defaultRange.end)); + } + } else { + setIsCustom(false); + const range = PRESETS[preset].getValue(); + onChange(range); + } + }; + + const handleCustomApply = () => { + if (customStart && customEnd) { + const range: DateTimeRange = { + start: new Date(customStart).toISOString(), + end: new Date(customEnd).toISOString(), + }; + onChange(range); + } + }; + + const handleCustomStartChange = (value: string) => { + setCustomStart(value); + }; + + const handleCustomEndChange = (value: string) => { + setCustomEnd(value); + }; + + return ( +
+
+ {(Object.keys(PRESETS) as PresetKey[]).map((key) => ( + + ))} +
+ + {isCustom && ( + + +
+ + handleCustomStartChange(e.target.value)} + /> +
+ +
+ + handleCustomEndChange(e.target.value)} + /> +
+ + +
+
+ )} +
+ ); +} diff --git a/ui/src/components/EventExpandedDetails.tsx b/ui/src/components/EventExpandedDetails.tsx index 1e6007ca..e3eaa100 100644 --- a/ui/src/components/EventExpandedDetails.tsx +++ b/ui/src/components/EventExpandedDetails.tsx @@ -1,228 +1,228 @@ -import { format } from 'date-fns'; -import type { K8sEvent } from '../types/k8s-event'; - -export interface EventExpandedDetailsProps { - /** The event to display details for */ - event: K8sEvent; -} - -/** - * Format timestamp for display (with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); - } catch { - return timestamp; - } -} - -/** - * Get the regarding object (handling both new and deprecated field names) - */ -function getRegarding(event: K8sEvent) { - return event.regarding || event.involvedObject || {}; -} - -/** - * Get the reporting controller (handling both new and deprecated field names) - */ -function getReportingController(event: K8sEvent): string | undefined { - return event.reportingController || event.reportingComponent || event.source?.component; -} - -/** - * Get the reporting instance (handling both new and deprecated field names) - */ -function getReportingInstance(event: K8sEvent): string | undefined { - return event.reportingInstance || event.source?.host; -} - -/** - * EventExpandedDetails renders the expanded details section for an event. - * - * Section order (most to least relevant for investigation): - * 1. Regarding Object - what resource is affected (was involvedObject in core/v1) - * 2. Timestamps - when it happened - * 3. Reporting Controller - what component generated the event (was source in core/v1) - * 4. Action - what action was taken/failed - * 5. Metadata - event UIDs and versions - */ -export function EventExpandedDetails({ event }: EventExpandedDetailsProps) { - const regarding = getRegarding(event); - const reportingController = getReportingController(event); - const reportingInstance = getReportingInstance(event); - const { eventTime, action, metadata, related } = event; - - // For backward compatibility, also check deprecated fields - // Note: events.k8s.io/v1 uses "deprecatedFirstTimestamp" and "deprecatedLastTimestamp" - const firstTimestamp = event.firstTimestamp || event.deprecatedFirstTimestamp; - const lastTimestamp = event.lastTimestamp || event.deprecatedLastTimestamp || event.series?.lastObservedTime; - const count = event.series?.count || event.count || event.deprecatedCount; - - return ( -
- {/* Regarding Object - Most actionable, shown first (was involvedObject in core/v1) */} -
-

- Regarding Object -

-
-
Kind:
-
{regarding.kind || 'Unknown'}
-
Name:
-
{regarding.name || 'Unknown'}
- {regarding.namespace && ( - <> -
Namespace:
-
{regarding.namespace}
- - )} - {regarding.apiVersion && ( - <> -
API Version:
-
{regarding.apiVersion}
- - )} - {regarding.uid && ( - <> -
UID:
-
{regarding.uid}
- - )} - {regarding.fieldPath && ( - <> -
Field Path:
-
{regarding.fieldPath}
- - )} -
-
- - {/* Timestamps */} -
-

- Timestamps -

-
- {eventTime && ( - <> -
Event Time:
-
{formatTimestampFull(eventTime)}
- - )} - {firstTimestamp && ( - <> -
First Seen:
-
{formatTimestampFull(firstTimestamp)}
- - )} - {lastTimestamp && ( - <> -
Last Seen:
-
{formatTimestampFull(lastTimestamp)}
- - )} - {count && count > 1 && ( - <> -
Count:
-
{count} times
- - )} -
-
- - {/* Reporting Controller (was Source in core/v1) */} - {(reportingController || reportingInstance) && ( -
-

- Reporting Controller -

-
- {reportingController && ( - <> -
Controller:
-
{reportingController}
- - )} - {reportingInstance && ( - <> -
Instance:
-
{reportingInstance}
- - )} -
-
- )} - - {/* Action */} - {action && ( -
-

- Action -

-

{action}

-
- )} - - {/* Related Object */} - {related && ( -
-

- Related Object -

-
- {related.kind && ( - <> -
Kind:
-
{related.kind}
- - )} - {related.name && ( - <> -
Name:
-
{related.name}
- - )} - {related.namespace && ( - <> -
Namespace:
-
{related.namespace}
- - )} -
-
- )} - - {/* Metadata */} - {metadata && ( -
-

- Metadata -

-
- {metadata.name && ( - <> -
Name:
-
{metadata.name}
- - )} - {metadata.uid && ( - <> -
UID:
-
{metadata.uid}
- - )} - {metadata.resourceVersion && ( - <> -
Resource Version:
-
{metadata.resourceVersion}
- - )} -
-
- )} -
- ); -} +import { format } from 'date-fns'; +import type { K8sEvent } from '../types/k8s-event'; + +export interface EventExpandedDetailsProps { + /** The event to display details for */ + event: K8sEvent; +} + +/** + * Format timestamp for display (with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + } catch { + return timestamp; + } +} + +/** + * Get the regarding object (handling both new and deprecated field names) + */ +function getRegarding(event: K8sEvent) { + return event.regarding || event.involvedObject || {}; +} + +/** + * Get the reporting controller (handling both new and deprecated field names) + */ +function getReportingController(event: K8sEvent): string | undefined { + return event.reportingController || event.reportingComponent || event.source?.component; +} + +/** + * Get the reporting instance (handling both new and deprecated field names) + */ +function getReportingInstance(event: K8sEvent): string | undefined { + return event.reportingInstance || event.source?.host; +} + +/** + * EventExpandedDetails renders the expanded details section for an event. + * + * Section order (most to least relevant for investigation): + * 1. Regarding Object - what resource is affected (was involvedObject in core/v1) + * 2. Timestamps - when it happened + * 3. Reporting Controller - what component generated the event (was source in core/v1) + * 4. Action - what action was taken/failed + * 5. Metadata - event UIDs and versions + */ +export function EventExpandedDetails({ event }: EventExpandedDetailsProps) { + const regarding = getRegarding(event); + const reportingController = getReportingController(event); + const reportingInstance = getReportingInstance(event); + const { eventTime, action, metadata, related } = event; + + // For backward compatibility, also check deprecated fields + // Note: events.k8s.io/v1 uses "deprecatedFirstTimestamp" and "deprecatedLastTimestamp" + const firstTimestamp = event.firstTimestamp || event.deprecatedFirstTimestamp; + const lastTimestamp = event.lastTimestamp || event.deprecatedLastTimestamp || event.series?.lastObservedTime; + const count = event.series?.count || event.count || event.deprecatedCount; + + return ( +
+ {/* Regarding Object - Most actionable, shown first (was involvedObject in core/v1) */} +
+

+ Regarding Object +

+
+
Kind:
+
{regarding.kind || 'Unknown'}
+
Name:
+
{regarding.name || 'Unknown'}
+ {regarding.namespace && ( + <> +
Namespace:
+
{regarding.namespace}
+ + )} + {regarding.apiVersion && ( + <> +
API Version:
+
{regarding.apiVersion}
+ + )} + {regarding.uid && ( + <> +
UID:
+
{regarding.uid}
+ + )} + {regarding.fieldPath && ( + <> +
Field Path:
+
{regarding.fieldPath}
+ + )} +
+
+ + {/* Timestamps */} +
+

+ Timestamps +

+
+ {eventTime && ( + <> +
Event Time:
+
{formatTimestampFull(eventTime)}
+ + )} + {firstTimestamp && ( + <> +
First Seen:
+
{formatTimestampFull(firstTimestamp)}
+ + )} + {lastTimestamp && ( + <> +
Last Seen:
+
{formatTimestampFull(lastTimestamp)}
+ + )} + {count && count > 1 && ( + <> +
Count:
+
{count} times
+ + )} +
+
+ + {/* Reporting Controller (was Source in core/v1) */} + {(reportingController || reportingInstance) && ( +
+

+ Reporting Controller +

+
+ {reportingController && ( + <> +
Controller:
+
{reportingController}
+ + )} + {reportingInstance && ( + <> +
Instance:
+
{reportingInstance}
+ + )} +
+
+ )} + + {/* Action */} + {action && ( +
+

+ Action +

+

{action}

+
+ )} + + {/* Related Object */} + {related && ( +
+

+ Related Object +

+
+ {related.kind && ( + <> +
Kind:
+
{related.kind}
+ + )} + {related.name && ( + <> +
Name:
+
{related.name}
+ + )} + {related.namespace && ( + <> +
Namespace:
+
{related.namespace}
+ + )} +
+
+ )} + + {/* Metadata */} + {metadata && ( +
+

+ Metadata +

+
+ {metadata.name && ( + <> +
Name:
+
{metadata.name}
+ + )} + {metadata.uid && ( + <> +
UID:
+
{metadata.uid}
+ + )} + {metadata.resourceVersion && ( + <> +
Resource Version:
+
{metadata.resourceVersion}
+ + )} +
+
+ )} +
+ ); +} diff --git a/ui/src/components/EventFeedItem.tsx b/ui/src/components/EventFeedItem.tsx index c90af15e..e9cecda8 100644 --- a/ui/src/components/EventFeedItem.tsx +++ b/ui/src/components/EventFeedItem.tsx @@ -1,311 +1,311 @@ -import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; -import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react'; -import type { K8sEvent } from '../types/k8s-event'; -import { EventExpandedDetails } from './EventExpandedDetails'; -import { cn } from '../lib/utils'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from './ui/tooltip'; - -export interface EventFeedItemProps { - /** The event to render */ - event: K8sEvent; - /** Handler called when the item is clicked */ - onEventClick?: (event: K8sEvent) => void; - /** Handler called when the resource name is clicked. If provided, the resource name becomes clickable. */ - onResourceClick?: (resource: { - kind: string; - name: string; - namespace?: string; - uid?: string; - }) => void; - /** Whether the item is selected */ - isSelected?: boolean; - /** Additional CSS class */ - className?: string; - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Whether this is a newly streamed event */ - isNew?: boolean; - /** Whether the item starts expanded */ - defaultExpanded?: boolean; -} - -/** - * Get the regarding object (handling both new and deprecated field names) - */ -function getRegarding(event: K8sEvent) { - return event.regarding || event.involvedObject || {}; -} - -/** - * Get the event note/message (handling both new and deprecated field names) - */ -function getNote(event: K8sEvent): string | undefined { - return event.note || event.message; -} - -/** - * Get the reporting controller (handling both new and deprecated field names) - */ -function getReportingController(event: K8sEvent): string | undefined { - return event.reportingController || event.source?.component; -} - -/** - * Get the event count (handling both new and deprecated field names) - */ -function getCount(event: K8sEvent): number | undefined { - return event.series?.count || event.count || event.deprecatedCount; -} - -/** - * Get the best timestamp to display (handling both new and deprecated field names) - * For recurring events (series), prefer lastObservedTime as it reflects the most recent occurrence. - * For single events, use eventTime. - */ -function getTimestamp(event: K8sEvent): string | undefined { - // For series events, lastObservedTime is the most recent occurrence - if (event.series?.lastObservedTime) { - return event.series.lastObservedTime; - } - // For single events, use eventTime (eventsv1) or fall back to deprecated/legacy fields - // Note: events.k8s.io/v1 uses "deprecatedFirstTimestamp" and "deprecatedLastTimestamp" - return ( - event.eventTime || - event.deprecatedLastTimestamp || - event.deprecatedFirstTimestamp || - event.lastTimestamp || - event.firstTimestamp - ); -} - -/** - * Format timestamp for display - */ -function formatTimestamp(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return formatDistanceToNow(date, { addSuffix: true }); - } catch { - return timestamp; - } -} - -/** - * Format timestamp for tooltip (human-friendly UTC format with timezone) - */ -function formatTimestampFull(timestamp?: string): string { - if (!timestamp) return 'Unknown time'; - try { - const date = new Date(timestamp); - return format(date, "MMMM d, yyyy 'at' h:mm:ss a 'UTC'"); - } catch { - return timestamp; - } -} - - -/** - * EventFeedItem renders a single Kubernetes event in the feed - */ -export function EventFeedItem({ - event, - onEventClick, - onResourceClick, - isSelected = false, - className = '', - compact = false, - isNew = false, - defaultExpanded = false, -}: EventFeedItemProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - const [isCopied, setIsCopied] = useState(false); - - // Use helper functions to handle both new and deprecated field names - const regarding = getRegarding(event); - const note = getNote(event); - const count = getCount(event); - const timestamp = getTimestamp(event); - const reportingController = getReportingController(event); - const { type, reason } = event; - - const handleClick = () => { - onEventClick?.(event); - }; - - const toggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsExpanded(!isExpanded); - }; - - const handleCopyResourceName = async (e: React.MouseEvent) => { - e.stopPropagation(); - if (regarding.name) { - try { - await navigator.clipboard.writeText(regarding.name); - setIsCopied(true); - setTimeout(() => setIsCopied(false), 2000); - } catch (err) { - console.error('Failed to copy resource name:', err); - } - } - }; - - const handleResourceClick = (e: React.MouseEvent) => { - e.stopPropagation(); - if (onResourceClick && regarding.name) { - onResourceClick({ - kind: regarding.kind || 'Unknown', - name: regarding.name, - namespace: regarding.namespace, - uid: regarding.uid, - }); - } - }; - - const isWarning = type === 'Warning'; - - return ( - - -
- {/* Main Content */} -
- {/* Header row: Type badge + Reason + Kind + Timestamp */} -
- {/* Type badge */} - - {type || 'Unknown'} - - - {/* Reason */} - {reason && ( - - {reason} - - )} - - {/* Involved Kind */} - {regarding.kind && ( - - {regarding.kind} - - )} - - {/* Spacer to push timestamp to the right */} - - - {/* Timestamp with tooltip */} - - - - {formatTimestamp(timestamp)} - - - -

{formatTimestampFull(timestamp)}

-
-
-
- - {/* Content row: Message + Object + Timestamp + Expand */} -
- {/* Note with count - takes remaining space */} - {note && ( -

- {note}{count && count > 1 && (x{count})} -

- )} - - {/* Regarding Object with Tooltip and Copy Button */} -
- - - - {regarding.name || 'Unknown'} - - - -

- {regarding.namespace - ? `${regarding.kind || 'Unknown'} in namespace ${regarding.namespace}` - : regarding.kind || 'Unknown'} -

-
-
- - - - - -

Click to copy

-
-
-
- - {/* Expand button - larger and positioned at the end */} - -
-
-
- - {/* Expanded Details */} - {isExpanded && } -
-
- ); -} +import { useState } from 'react'; +import { format, formatDistanceToNow } from 'date-fns'; +import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react'; +import type { K8sEvent } from '../types/k8s-event'; +import { EventExpandedDetails } from './EventExpandedDetails'; +import { cn } from '../lib/utils'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from './ui/tooltip'; + +export interface EventFeedItemProps { + /** The event to render */ + event: K8sEvent; + /** Handler called when the item is clicked */ + onEventClick?: (event: K8sEvent) => void; + /** Handler called when the resource name is clicked. If provided, the resource name becomes clickable. */ + onResourceClick?: (resource: { + kind: string; + name: string; + namespace?: string; + uid?: string; + }) => void; + /** Whether the item is selected */ + isSelected?: boolean; + /** Additional CSS class */ + className?: string; + /** Whether to show as compact (for resource detail tabs) */ + compact?: boolean; + /** Whether this is a newly streamed event */ + isNew?: boolean; + /** Whether the item starts expanded */ + defaultExpanded?: boolean; +} + +/** + * Get the regarding object (handling both new and deprecated field names) + */ +function getRegarding(event: K8sEvent) { + return event.regarding || event.involvedObject || {}; +} + +/** + * Get the event note/message (handling both new and deprecated field names) + */ +function getNote(event: K8sEvent): string | undefined { + return event.note || event.message; +} + +/** + * Get the reporting controller (handling both new and deprecated field names) + */ +function getReportingController(event: K8sEvent): string | undefined { + return event.reportingController || event.source?.component; +} + +/** + * Get the event count (handling both new and deprecated field names) + */ +function getCount(event: K8sEvent): number | undefined { + return event.series?.count || event.count || event.deprecatedCount; +} + +/** + * Get the best timestamp to display (handling both new and deprecated field names) + * For recurring events (series), prefer lastObservedTime as it reflects the most recent occurrence. + * For single events, use eventTime. + */ +function getTimestamp(event: K8sEvent): string | undefined { + // For series events, lastObservedTime is the most recent occurrence + if (event.series?.lastObservedTime) { + return event.series.lastObservedTime; + } + // For single events, use eventTime (eventsv1) or fall back to deprecated/legacy fields + // Note: events.k8s.io/v1 uses "deprecatedFirstTimestamp" and "deprecatedLastTimestamp" + return ( + event.eventTime || + event.deprecatedLastTimestamp || + event.deprecatedFirstTimestamp || + event.lastTimestamp || + event.firstTimestamp + ); +} + +/** + * Format timestamp for display + */ +function formatTimestamp(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + const date = new Date(timestamp); + return formatDistanceToNow(date, { addSuffix: true }); + } catch { + return timestamp; + } +} + +/** + * Format timestamp for tooltip (human-friendly UTC format with timezone) + */ +function formatTimestampFull(timestamp?: string): string { + if (!timestamp) return 'Unknown time'; + try { + const date = new Date(timestamp); + return format(date, "MMMM d, yyyy 'at' h:mm:ss a 'UTC'"); + } catch { + return timestamp; + } +} + + +/** + * EventFeedItem renders a single Kubernetes event in the feed + */ +export function EventFeedItem({ + event, + onEventClick, + onResourceClick, + isSelected = false, + className = '', + compact = false, + isNew = false, + defaultExpanded = false, +}: EventFeedItemProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [isCopied, setIsCopied] = useState(false); + + // Use helper functions to handle both new and deprecated field names + const regarding = getRegarding(event); + const note = getNote(event); + const count = getCount(event); + const timestamp = getTimestamp(event); + const reportingController = getReportingController(event); + const { type, reason } = event; + + const handleClick = () => { + onEventClick?.(event); + }; + + const toggleExpand = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsExpanded(!isExpanded); + }; + + const handleCopyResourceName = async (e: React.MouseEvent) => { + e.stopPropagation(); + if (regarding.name) { + try { + await navigator.clipboard.writeText(regarding.name); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + } catch (err) { + console.error('Failed to copy resource name:', err); + } + } + }; + + const handleResourceClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (onResourceClick && regarding.name) { + onResourceClick({ + kind: regarding.kind || 'Unknown', + name: regarding.name, + namespace: regarding.namespace, + uid: regarding.uid, + }); + } + }; + + const isWarning = type === 'Warning'; + + return ( + + +
+ {/* Main Content */} +
+ {/* Header row: Type badge + Reason + Kind + Timestamp */} +
+ {/* Type badge */} + + {type || 'Unknown'} + + + {/* Reason */} + {reason && ( + + {reason} + + )} + + {/* Involved Kind */} + {regarding.kind && ( + + {regarding.kind} + + )} + + {/* Spacer to push timestamp to the right */} + + + {/* Timestamp with tooltip */} + + + + {formatTimestamp(timestamp)} + + + +

{formatTimestampFull(timestamp)}

+
+
+
+ + {/* Content row: Message + Object + Timestamp + Expand */} +
+ {/* Note with count - takes remaining space */} + {note && ( +

+ {note}{count && count > 1 && (x{count})} +

+ )} + + {/* Regarding Object with Tooltip and Copy Button */} +
+ + + + {regarding.name || 'Unknown'} + + + +

+ {regarding.namespace + ? `${regarding.kind || 'Unknown'} in namespace ${regarding.namespace}` + : regarding.kind || 'Unknown'} +

+
+
+ + + + + +

Click to copy

+
+
+
+ + {/* Expand button - larger and positioned at the end */} + +
+
+
+ + {/* Expanded Details */} + {isExpanded && } +
+
+ ); +} diff --git a/ui/src/components/EventFeedItemSkeleton.tsx b/ui/src/components/EventFeedItemSkeleton.tsx index 667e88d0..e95d5bdb 100644 --- a/ui/src/components/EventFeedItemSkeleton.tsx +++ b/ui/src/components/EventFeedItemSkeleton.tsx @@ -1,47 +1,47 @@ -import { Card } from './ui/card'; -import { Skeleton } from './ui/skeleton'; -import { cn } from '../lib/utils'; - -export interface EventFeedItemSkeletonProps { - /** Whether to show as compact (for resource detail tabs) */ - compact?: boolean; - /** Additional CSS class */ - className?: string; -} - -/** - * EventFeedItemSkeleton renders a loading placeholder that matches EventFeedItem layout - */ -export function EventFeedItemSkeleton({ - compact = false, - className = '', -}: EventFeedItemSkeletonProps) { - return ( - -
- {/* Main Content */} -
- {/* Single row layout: Message + Object + Timestamp + Expand */} -
- {/* Note skeleton - takes remaining space */} - - - {/* Regarding Object skeleton */} - - - {/* Timestamp skeleton */} - - - {/* Expand button skeleton */} - -
-
-
-
- ); -} +import { Card } from './ui/card'; +import { Skeleton } from './ui/skeleton'; +import { cn } from '../lib/utils'; + +export interface EventFeedItemSkeletonProps { + /** Whether to show as compact (for resource detail tabs) */ + compact?: boolean; + /** Additional CSS class */ + className?: string; +} + +/** + * EventFeedItemSkeleton renders a loading placeholder that matches EventFeedItem layout + */ +export function EventFeedItemSkeleton({ + compact = false, + className = '', +}: EventFeedItemSkeletonProps) { + return ( + +
+ {/* Main Content */} +
+ {/* Single row layout: Message + Object + Timestamp + Expand */} +
+ {/* Note skeleton - takes remaining space */} + + + {/* Regarding Object skeleton */} + + + {/* Timestamp skeleton */} + + + {/* Expand button skeleton */} + +
+
+
+
+ ); +} diff --git a/ui/src/components/EventTypeToggle.tsx b/ui/src/components/EventTypeToggle.tsx index 0da790d5..6961a9ec 100644 --- a/ui/src/components/EventTypeToggle.tsx +++ b/ui/src/components/EventTypeToggle.tsx @@ -1,76 +1,76 @@ -import { Button } from './ui/button'; -import { cn } from '../lib/utils'; -import type { K8sEventType } from '../types/k8s-event'; - -export type EventTypeOption = K8sEventType | 'all'; - -export interface EventTypeToggleProps { - /** Current selected value */ - value: EventTypeOption; - /** Handler called when selection changes */ - onChange: (value: EventTypeOption) => void; - /** Additional CSS class */ - className?: string; - /** Whether the toggle is disabled */ - disabled?: boolean; -} - -/** - * Options for the event type toggle - */ -const OPTIONS: { value: EventTypeOption; label: string; description: string }[] = [ - { - value: 'all', - label: 'All', - description: 'Show all events', - }, - { - value: 'Normal', - label: 'Normal', - description: 'Show only normal events', - }, - { - value: 'Warning', - label: 'Warning', - description: 'Show only warning events', - }, -]; - -/** - * EventTypeToggle provides a segmented control for filtering by event type - */ -export function EventTypeToggle({ - value, - onChange, - className = '', - disabled = false, -}: EventTypeToggleProps) { - return ( -
- {OPTIONS.map((option, index) => ( - - ))} -
- ); -} +import { Button } from './ui/button'; +import { cn } from '../lib/utils'; +import type { K8sEventType } from '../types/k8s-event'; + +export type EventTypeOption = K8sEventType | 'all'; + +export interface EventTypeToggleProps { + /** Current selected value */ + value: EventTypeOption; + /** Handler called when selection changes */ + onChange: (value: EventTypeOption) => void; + /** Additional CSS class */ + className?: string; + /** Whether the toggle is disabled */ + disabled?: boolean; +} + +/** + * Options for the event type toggle + */ +const OPTIONS: { value: EventTypeOption; label: string; description: string }[] = [ + { + value: 'all', + label: 'All', + description: 'Show all events', + }, + { + value: 'Normal', + label: 'Normal', + description: 'Show only normal events', + }, + { + value: 'Warning', + label: 'Warning', + description: 'Show only warning events', + }, +]; + +/** + * EventTypeToggle provides a segmented control for filtering by event type + */ +export function EventTypeToggle({ + value, + onChange, + className = '', + disabled = false, +}: EventTypeToggleProps) { + return ( +
+ {OPTIONS.map((option, index) => ( + + ))} +
+ ); +} diff --git a/ui/src/components/EventsFeed.tsx b/ui/src/components/EventsFeed.tsx index 32f5249c..113d55d0 100644 --- a/ui/src/components/EventsFeed.tsx +++ b/ui/src/components/EventsFeed.tsx @@ -1,315 +1,315 @@ -import { useEffect, useRef, useCallback } from 'react'; -import type { K8sEvent } from '../types/k8s-event'; -import type { EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; -import type { - EventsFeedFilters as FilterState, - TimeRange, -} from '../hooks/useEventsFeed'; -import { useEventsFeed } from '../hooks/useEventsFeed'; -import { EventFeedItem } from './EventFeedItem'; -import { EventFeedItemSkeleton } from './EventFeedItemSkeleton'; -import { EventsFeedFilters } from './EventsFeedFilters'; -import { ActivityApiClient } from '../api/client'; -import { Button } from './ui/button'; -import { Card } from './ui/card'; -import { Badge } from './ui/badge'; -import { ApiErrorAlert } from './ApiErrorAlert'; - -export interface EventsFeedProps { - /** API client instance */ - client: ActivityApiClient; - /** Initial filter settings */ - initialFilters?: FilterState; - /** Initial time range */ - initialTimeRange?: TimeRange; - /** Number of items per page */ - pageSize?: number; - /** Handler called when an event is clicked */ - onEventClick?: (event: K8sEvent) => void; - /** Handler called when a resource name is clicked. If provided, resource names become clickable. */ - onResourceClick?: (resource: { - kind: string; - name: string; - namespace?: string; - uid?: string; - }) => void; - /** Whether to show in compact mode (for resource detail tabs) */ - compact?: boolean; - /** Filter to a specific namespace */ - namespace?: string; - /** Whether to show filters */ - showFilters?: boolean; - /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName' | 'eventType'>; - /** Additional CSS class */ - className?: string; - /** Enable infinite scroll (default: true) */ - infiniteScroll?: boolean; - /** Threshold in pixels for triggering load more (default: 200) */ - loadMoreThreshold?: number; - /** Enable real-time streaming (default: false) */ - enableStreaming?: boolean; - /** Callback invoked when the effective time range is resolved */ - onEffectiveTimeRangeChange?: EffectiveTimeRangeCallback; - /** Custom error formatter for customizing error messages */ - errorFormatter?: ErrorFormatter; - /** Callback invoked when filters or time range change (useful for URL state management) */ - onFiltersChange?: (filters: FilterState, timeRange: TimeRange) => void; -} - -/** - * EventsFeed displays a chronological list of Kubernetes events with filtering and pagination. - * Supports optional real-time streaming of new events. - */ -export function EventsFeed({ - client, - initialFilters = {}, - initialTimeRange = { start: 'now-24h' }, - pageSize = 50, - onEventClick, - onResourceClick, - compact = false, - namespace, - showFilters = true, - hiddenFilters = [], - className = '', - infiniteScroll = true, - loadMoreThreshold = 200, - enableStreaming = false, - onEffectiveTimeRangeChange, - errorFormatter, - onFiltersChange: onFiltersChangeProp, -}: EventsFeedProps) { - // Merge namespace into initial filters if provided - const mergedInitialFilters: FilterState = { - ...initialFilters, - }; - - const { - events, - isLoading, - isRefreshing, - error, - hasMore, - filters, - timeRange, - refresh, - loadMore, - setFilters, - setTimeRange, - isStreaming, - startStreaming, - stopStreaming, - newEventsCount, - } = useEventsFeed({ - client, - initialFilters: mergedInitialFilters, - initialTimeRange, - pageSize, - namespace, - enableStreaming, - autoStartStreaming: true, - }); - - const scrollContainerRef = useRef(null); - const loadMoreTriggerRef = useRef(null); - // Store the latest loadMore function in a ref to avoid observer re-subscription - const loadMoreRef = useRef(loadMore); - - // Auto-execute on mount - useEffect(() => { - refresh(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - // Update the ref whenever loadMore changes - useEffect(() => { - loadMoreRef.current = loadMore; - }, [loadMore]); - - // Infinite scroll using Intersection Observer - useEffect(() => { - if (!infiniteScroll || !loadMoreTriggerRef.current) return; - - const observer = new IntersectionObserver( - (entries) => { - const entry = entries[0]; - if (entry.isIntersecting && hasMore && !isLoading) { - // Call through the ref to always use the latest function - loadMoreRef.current(); - } - }, - { - root: scrollContainerRef.current, - rootMargin: `${loadMoreThreshold}px`, - threshold: 0, - } - ); - - observer.observe(loadMoreTriggerRef.current); - - return () => { - observer.disconnect(); - }; - }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); - - // Handle filter changes - refresh is automatic via the hook - const handleFiltersChange = useCallback( - (newFilters: FilterState) => { - setFilters(newFilters); - onFiltersChangeProp?.(newFilters, timeRange); - }, - [setFilters, onFiltersChangeProp, timeRange] - ); - - // Handle time range changes - refresh is automatic via the hook - const handleTimeRangeChange = useCallback( - (newTimeRange: TimeRange) => { - setTimeRange(newTimeRange); - onFiltersChangeProp?.(filters, newTimeRange); - }, - [setTimeRange, onFiltersChangeProp, filters] - ); - - // Handle manual load more click - const handleLoadMoreClick = useCallback(() => { - loadMore(); - }, [loadMore]); - - // Handle streaming toggle - const handleStreamingToggle = useCallback(() => { - if (isStreaming) { - stopStreaming(); - } else { - startStreaming(); - } - }, [isStreaming, startStreaming, stopStreaming]); - - // Build container classes - use flex layout to properly fill available space - // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling - const containerClasses = compact - ? `flex-1 min-h-0 flex flex-col p-2 shadow-none border-border ${className}` - : `flex-1 min-h-0 flex flex-col p-3 ${className}`; - - // Build list classes - use flex-1 min-h-0 for flex-based scrolling - const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2'; - - return ( - - {/* Header with streaming status */} - {enableStreaming && ( -
-
- {isStreaming && ( -
- - - - - Streaming events... -
- )} - {newEventsCount > 0 && ( - - +{newEventsCount} new - - )} -
- -
- )} - - {/* Filters */} - {showFilters && ( - - )} - - {/* Error Display */} - - - {/* Event List */} -
- {/* Skeleton Loading State - show when loading/refreshing and no items yet */} - {(isLoading || isRefreshing) && events.length === 0 && ( - <> - {Array.from({ length: 8 }).map((_, index) => ( - - ))} - - )} - - {/* Empty State - only show when not loading/refreshing */} - {!isLoading && !isRefreshing && events.length === 0 && ( -
-

No events found

-

- Try adjusting your filters or time range -

-
- )} - - {events.map((event, index) => ( - - ))} - - {/* Load More Trigger for Infinite Scroll */} - {infiniteScroll && hasMore && ( -
- )} - - {/* Load More Button (when infinite scroll is disabled) */} - {!infiniteScroll && hasMore && !isLoading && ( -
- -
- )} - - {/* End of Results */} - {!hasMore && events.length > 0 && !isLoading && ( -
- No more events to load -
- )} -
- - ); -} +import { useEffect, useRef, useCallback } from 'react'; +import type { K8sEvent } from '../types/k8s-event'; +import type { EffectiveTimeRangeCallback, ErrorFormatter } from '../types/activity'; +import type { + EventsFeedFilters as FilterState, + TimeRange, +} from '../hooks/useEventsFeed'; +import { useEventsFeed } from '../hooks/useEventsFeed'; +import { EventFeedItem } from './EventFeedItem'; +import { EventFeedItemSkeleton } from './EventFeedItemSkeleton'; +import { EventsFeedFilters } from './EventsFeedFilters'; +import { ActivityApiClient } from '../api/client'; +import { Button } from './ui/button'; +import { Card } from './ui/card'; +import { Badge } from './ui/badge'; +import { ApiErrorAlert } from './ApiErrorAlert'; + +export interface EventsFeedProps { + /** API client instance */ + client: ActivityApiClient; + /** Initial filter settings */ + initialFilters?: FilterState; + /** Initial time range */ + initialTimeRange?: TimeRange; + /** Number of items per page */ + pageSize?: number; + /** Handler called when an event is clicked */ + onEventClick?: (event: K8sEvent) => void; + /** Handler called when a resource name is clicked. If provided, resource names become clickable. */ + onResourceClick?: (resource: { + kind: string; + name: string; + namespace?: string; + uid?: string; + }) => void; + /** Whether to show in compact mode (for resource detail tabs) */ + compact?: boolean; + /** Filter to a specific namespace */ + namespace?: string; + /** Whether to show filters */ + showFilters?: boolean; + /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ + hiddenFilters?: Array<'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName' | 'eventType'>; + /** Additional CSS class */ + className?: string; + /** Enable infinite scroll (default: true) */ + infiniteScroll?: boolean; + /** Threshold in pixels for triggering load more (default: 200) */ + loadMoreThreshold?: number; + /** Enable real-time streaming (default: false) */ + enableStreaming?: boolean; + /** Callback invoked when the effective time range is resolved */ + onEffectiveTimeRangeChange?: EffectiveTimeRangeCallback; + /** Custom error formatter for customizing error messages */ + errorFormatter?: ErrorFormatter; + /** Callback invoked when filters or time range change (useful for URL state management) */ + onFiltersChange?: (filters: FilterState, timeRange: TimeRange) => void; +} + +/** + * EventsFeed displays a chronological list of Kubernetes events with filtering and pagination. + * Supports optional real-time streaming of new events. + */ +export function EventsFeed({ + client, + initialFilters = {}, + initialTimeRange = { start: 'now-24h' }, + pageSize = 50, + onEventClick, + onResourceClick, + compact = false, + namespace, + showFilters = true, + hiddenFilters = [], + className = '', + infiniteScroll = true, + loadMoreThreshold = 200, + enableStreaming = false, + onEffectiveTimeRangeChange, + errorFormatter, + onFiltersChange: onFiltersChangeProp, +}: EventsFeedProps) { + // Merge namespace into initial filters if provided + const mergedInitialFilters: FilterState = { + ...initialFilters, + }; + + const { + events, + isLoading, + isRefreshing, + error, + hasMore, + filters, + timeRange, + refresh, + loadMore, + setFilters, + setTimeRange, + isStreaming, + startStreaming, + stopStreaming, + newEventsCount, + } = useEventsFeed({ + client, + initialFilters: mergedInitialFilters, + initialTimeRange, + pageSize, + namespace, + enableStreaming, + autoStartStreaming: true, + }); + + const scrollContainerRef = useRef(null); + const loadMoreTriggerRef = useRef(null); + // Store the latest loadMore function in a ref to avoid observer re-subscription + const loadMoreRef = useRef(loadMore); + + // Auto-execute on mount + useEffect(() => { + refresh(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Update the ref whenever loadMore changes + useEffect(() => { + loadMoreRef.current = loadMore; + }, [loadMore]); + + // Infinite scroll using Intersection Observer + useEffect(() => { + if (!infiniteScroll || !loadMoreTriggerRef.current) return; + + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry.isIntersecting && hasMore && !isLoading) { + // Call through the ref to always use the latest function + loadMoreRef.current(); + } + }, + { + root: scrollContainerRef.current, + rootMargin: `${loadMoreThreshold}px`, + threshold: 0, + } + ); + + observer.observe(loadMoreTriggerRef.current); + + return () => { + observer.disconnect(); + }; + }, [infiniteScroll, hasMore, isLoading, loadMoreThreshold]); + + // Handle filter changes - refresh is automatic via the hook + const handleFiltersChange = useCallback( + (newFilters: FilterState) => { + setFilters(newFilters); + onFiltersChangeProp?.(newFilters, timeRange); + }, + [setFilters, onFiltersChangeProp, timeRange] + ); + + // Handle time range changes - refresh is automatic via the hook + const handleTimeRangeChange = useCallback( + (newTimeRange: TimeRange) => { + setTimeRange(newTimeRange); + onFiltersChangeProp?.(filters, newTimeRange); + }, + [setTimeRange, onFiltersChangeProp, filters] + ); + + // Handle manual load more click + const handleLoadMoreClick = useCallback(() => { + loadMore(); + }, [loadMore]); + + // Handle streaming toggle + const handleStreamingToggle = useCallback(() => { + if (isStreaming) { + stopStreaming(); + } else { + startStreaming(); + } + }, [isStreaming, startStreaming, stopStreaming]); + + // Build container classes - use flex layout to properly fill available space + // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling + const containerClasses = compact + ? `flex-1 min-h-0 flex flex-col p-2 shadow-none border-border ${className}` + : `flex-1 min-h-0 flex flex-col p-3 ${className}`; + + // Build list classes - use flex-1 min-h-0 for flex-based scrolling + const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2'; + + return ( + + {/* Header with streaming status */} + {enableStreaming && ( +
+
+ {isStreaming && ( +
+ + + + + Streaming events... +
+ )} + {newEventsCount > 0 && ( + + +{newEventsCount} new + + )} +
+ +
+ )} + + {/* Filters */} + {showFilters && ( + + )} + + {/* Error Display */} + + + {/* Event List */} +
+ {/* Skeleton Loading State - show when loading/refreshing and no items yet */} + {(isLoading || isRefreshing) && events.length === 0 && ( + <> + {Array.from({ length: 8 }).map((_, index) => ( + + ))} + + )} + + {/* Empty State - only show when not loading/refreshing */} + {!isLoading && !isRefreshing && events.length === 0 && ( +
+

No events found

+

+ Try adjusting your filters or time range +

+
+ )} + + {events.map((event, index) => ( + + ))} + + {/* Load More Trigger for Infinite Scroll */} + {infiniteScroll && hasMore && ( +
+ )} + + {/* Load More Button (when infinite scroll is disabled) */} + {!infiniteScroll && hasMore && !isLoading && ( +
+ +
+ )} + + {/* End of Results */} + {!hasMore && events.length > 0 && !isLoading && ( +
+ No more events to load +
+ )} +
+ + ); +} diff --git a/ui/src/components/EventsFeedFilters.tsx b/ui/src/components/EventsFeedFilters.tsx index 724e0fd5..fbf085d4 100644 --- a/ui/src/components/EventsFeedFilters.tsx +++ b/ui/src/components/EventsFeedFilters.tsx @@ -1,410 +1,410 @@ -import { useState, useCallback, useEffect } from 'react'; -import { formatISO, subDays } from 'date-fns'; -import { Search } from 'lucide-react'; - -import type { EventsFeedFilters as FilterState } from '../hooks/useEventsFeed'; -import type { TimeRange } from '../hooks/useEventsFeed'; -import type { ActivityApiClient } from '../api/client'; -import { useEventFacets } from '../hooks/useEventFacets'; -import { EventTypeToggle, EventTypeOption } from './EventTypeToggle'; -import { TimeRangeDropdown } from './ui/time-range-dropdown'; -import { FilterChip } from './ui/filter-chip'; -import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; -import { Input } from './ui/input'; - -export interface EventsFeedFiltersProps { - /** API client instance for fetching facets */ - client: ActivityApiClient; - /** Current filter state */ - filters: FilterState; - /** Current time range */ - timeRange: TimeRange; - /** Handler called when filters change */ - onFiltersChange: (filters: FilterState) => void; - /** Handler called when time range changes */ - onTimeRangeChange: (timeRange: TimeRange) => void; - /** Whether the filters are disabled (e.g., during loading) */ - disabled?: boolean; - /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ - hiddenFilters?: Array<'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName' | 'eventType'>; - /** Additional CSS class */ - className?: string; - /** Namespace filter (when scoped to a specific namespace) */ - namespace?: string; -} - -/** - * Preset time ranges - */ -const TIME_PRESETS = [ - { key: 'now-1h', label: 'Last hour' }, - { key: 'now-6h', label: 'Last 6 hours' }, - { key: 'now-24h', label: 'Last 24 hours' }, - { key: 'now-7d', label: 'Last 7 days' }, - { key: 'now-30d', label: 'Last 30 days' }, -]; - -/** - * Filter configuration registry - */ -type FilterId = 'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName'; - -interface FilterConfig { - id: FilterId; - label: string; - inputMode: 'typeahead' | 'text'; - placeholder?: string; - searchPlaceholder?: string; -} - -const FILTER_CONFIGS: Record = { - involvedKinds: { - id: 'involvedKinds', - label: 'Kind', - inputMode: 'typeahead', - searchPlaceholder: 'Search kinds...', - }, - reasons: { - id: 'reasons', - label: 'Reason', - inputMode: 'typeahead', - searchPlaceholder: 'Search reasons...', - }, - namespaces: { - id: 'namespaces', - label: 'Namespace', - inputMode: 'typeahead', - searchPlaceholder: 'Search namespaces...', - }, - sourceComponents: { - id: 'sourceComponents', - label: 'Source', - inputMode: 'typeahead', - searchPlaceholder: 'Search sources...', - }, - involvedName: { - id: 'involvedName', - label: 'Resource Name', - inputMode: 'text', - placeholder: 'Enter resource name...', - }, -}; - -/** - * Helper function to convert ISO string to datetime-local format - */ -const formatDatetimeLocal = (isoString: string): string => { - const date = new Date(isoString); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; -}; - -/** - * Check if the current time range matches a preset - */ -const getSelectedPreset = (timeRange: TimeRange): string => { - const preset = TIME_PRESETS.find((p) => timeRange.start === p.key); - return preset ? preset.key : 'custom'; -}; - -/** - * EventsFeedFilters provides filter controls for the events feed - */ -export function EventsFeedFilters({ - client, - filters, - timeRange, - onFiltersChange, - onTimeRangeChange, - disabled = false, - hiddenFilters = [], - className = '', - namespace, -}: EventsFeedFiltersProps) { - const { involvedKinds, reasons, namespaces, sourceComponents, error: facetsError } = useEventFacets(client, timeRange, filters); - - // Log facets error for debugging - if (facetsError) { - console.error('Failed to load event facets:', facetsError); - } - - // Track which filter was just added to auto-open it - const [pendingFilter, setPendingFilter] = useState(null); - - // Custom time range state - const selectedPreset = getSelectedPreset(timeRange); - const [customStart, setCustomStart] = useState(() => { - if (selectedPreset === 'custom') { - return formatDatetimeLocal(timeRange.start); - } - return formatDatetimeLocal(formatISO(subDays(new Date(), 1))); - }); - const [customEnd, setCustomEnd] = useState(() => { - if (selectedPreset === 'custom' && timeRange.end) { - return formatDatetimeLocal(timeRange.end); - } - return formatDatetimeLocal(formatISO(new Date())); - }); - - // Handle event type change - const handleEventTypeChange = useCallback( - (value: EventTypeOption) => { - onFiltersChange({ - ...filters, - eventType: value === 'all' ? undefined : value, - }); - }, - [filters, onFiltersChange] - ); - - // Handle time range preset selection - const handleTimePresetSelect = useCallback( - (presetKey: string) => { - onTimeRangeChange({ - start: presetKey, - end: undefined, - }); - }, - [onTimeRangeChange] - ); - - // Handle custom time range apply - const handleCustomRangeApply = useCallback( - (start: string, end: string) => { - setCustomStart(start); - setCustomEnd(end); - onTimeRangeChange({ - start: new Date(start).toISOString(), - end: new Date(end).toISOString(), - }); - }, - [onTimeRangeChange] - ); - - // Get display label for time range - const getTimeRangeLabel = () => { - const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); - if (preset) return preset.label; - if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { - const start = new Date(timeRange.start); - const end = new Date(timeRange.end); - return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; - } - return 'Select time range'; - }; - - // Get current event type value for toggle - const eventTypeValue: EventTypeOption = filters.eventType || 'all'; - - // Determine which filters are currently active (have values) and not hidden - const filtersWithValues: FilterId[] = []; - if (filters.involvedKinds && filters.involvedKinds.length > 0 && !hiddenFilters.includes('involvedKinds')) filtersWithValues.push('involvedKinds'); - if (filters.reasons && filters.reasons.length > 0 && !hiddenFilters.includes('reasons')) filtersWithValues.push('reasons'); - if (!namespace && filters.namespaces && filters.namespaces.length > 0 && !hiddenFilters.includes('namespaces')) filtersWithValues.push('namespaces'); - if (filters.sourceComponents && filters.sourceComponents.length > 0 && !hiddenFilters.includes('sourceComponents')) filtersWithValues.push('sourceComponents'); - if (filters.involvedName && !hiddenFilters.includes('involvedName')) filtersWithValues.push('involvedName'); - - // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters - const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) - ? [...filtersWithValues, pendingFilter] - : filtersWithValues; - - // Clear pending filter when filter values change (user selected something) - useEffect(() => { - if (pendingFilter && filtersWithValues.includes(pendingFilter)) { - // Filter now has values, clear pending state - setPendingFilter(null); - } - }, [pendingFilter, filtersWithValues]); - - // Build available filters list (exclude namespace if scoped, exclude hidden filters) - const availableFilters: FilterOption[] = [ - { id: 'involvedKinds', label: 'Kind' }, - { id: 'reasons', label: 'Reason' }, - ...(namespace ? [] : [{ id: 'namespaces' as const, label: 'Namespace' }]), - { id: 'sourceComponents', label: 'Source' }, - { id: 'involvedName', label: 'Resource Name' }, - ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); - - // Handle adding a filter - const handleAddFilter = useCallback((filterId: string) => { - setPendingFilter(filterId as FilterId); - }, []); - - // Handle popover close - clear pending filter if no values were selected - const handlePopoverClose = useCallback( - (filterId: FilterId) => { - if (pendingFilter === filterId) { - const hasValues = (() => { - const value = filters[filterId]; - if (filterId === 'involvedName') return !!value; - return Array.isArray(value) && value.length > 0; - })(); - if (!hasValues) { - setPendingFilter(null); - } - } - }, - [pendingFilter, filters] - ); - - // Handle filter value changes - const handleFilterChange = useCallback( - (filterId: FilterId, values: string[]) => { - onFiltersChange({ - ...filters, - [filterId]: values.length > 0 ? values : undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Handle filter clear - const handleFilterClear = useCallback( - (filterId: FilterId) => { - onFiltersChange({ - ...filters, - [filterId]: undefined, - }); - }, - [filters, onFiltersChange] - ); - - // Get options for a specific filter - const getFilterOptions = (filterId: FilterId) => { - switch (filterId) { - case 'involvedKinds': - return involvedKinds - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'reasons': - return reasons - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'namespaces': - return namespaces - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - case 'sourceComponents': - return sourceComponents - .filter((facet) => facet.value) - .map((facet) => ({ - value: facet.value, - label: facet.value, - count: facet.count, - })); - default: - return []; - } - }; - - // Get values for a specific filter - const getFilterValues = (filterId: FilterId): string[] => { - const value = filters[filterId]; - if (filterId === 'involvedName') { - return value ? [value as string] : []; - } - return (value as string[] | undefined) || []; - }; - - // Handle search input change with debouncing - const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const value = event.target.value; - onFiltersChange({ - ...filters, - search: value || undefined, - }); - }, - [filters, onFiltersChange] - ); - - return ( -
-
- {/* Event Type Toggle */} - {!hiddenFilters.includes('eventType') && ( - - )} - - {/* Search Input */} -
- - -
- - {/* Active Filter Chips */} - {activeFilterIds.map((filterId) => { - const config = FILTER_CONFIGS[filterId]; - return ( - handleFilterChange(filterId, values)} - onClear={() => handleFilterClear(filterId)} - onPopoverClose={() => handlePopoverClose(filterId)} - inputMode={config.inputMode} - placeholder={config.placeholder} - searchPlaceholder={config.searchPlaceholder} - autoOpen={pendingFilter === filterId} - disabled={disabled} - /> - ); - })} - - {/* Add Filter Dropdown */} - 0} - disabled={disabled} - /> - - {/* Spacer */} -
- - {/* Time Range Dropdown */} - -
-
- ); -} +import { useState, useCallback, useEffect } from 'react'; +import { formatISO, subDays } from 'date-fns'; +import { Search } from 'lucide-react'; + +import type { EventsFeedFilters as FilterState } from '../hooks/useEventsFeed'; +import type { TimeRange } from '../hooks/useEventsFeed'; +import type { ActivityApiClient } from '../api/client'; +import { useEventFacets } from '../hooks/useEventFacets'; +import { EventTypeToggle, EventTypeOption } from './EventTypeToggle'; +import { TimeRangeDropdown } from './ui/time-range-dropdown'; +import { FilterChip } from './ui/filter-chip'; +import { AddFilterDropdown, type FilterOption } from './ui/add-filter-dropdown'; +import { Input } from './ui/input'; + +export interface EventsFeedFiltersProps { + /** API client instance for fetching facets */ + client: ActivityApiClient; + /** Current filter state */ + filters: FilterState; + /** Current time range */ + timeRange: TimeRange; + /** Handler called when filters change */ + onFiltersChange: (filters: FilterState) => void; + /** Handler called when time range changes */ + onTimeRangeChange: (timeRange: TimeRange) => void; + /** Whether the filters are disabled (e.g., during loading) */ + disabled?: boolean; + /** Filters that should be locked and hidden from the UI (programmatically set by parent) */ + hiddenFilters?: Array<'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName' | 'eventType'>; + /** Additional CSS class */ + className?: string; + /** Namespace filter (when scoped to a specific namespace) */ + namespace?: string; +} + +/** + * Preset time ranges + */ +const TIME_PRESETS = [ + { key: 'now-1h', label: 'Last hour' }, + { key: 'now-6h', label: 'Last 6 hours' }, + { key: 'now-24h', label: 'Last 24 hours' }, + { key: 'now-7d', label: 'Last 7 days' }, + { key: 'now-30d', label: 'Last 30 days' }, +]; + +/** + * Filter configuration registry + */ +type FilterId = 'involvedKinds' | 'reasons' | 'namespaces' | 'sourceComponents' | 'involvedName'; + +interface FilterConfig { + id: FilterId; + label: string; + inputMode: 'typeahead' | 'text'; + placeholder?: string; + searchPlaceholder?: string; +} + +const FILTER_CONFIGS: Record = { + involvedKinds: { + id: 'involvedKinds', + label: 'Kind', + inputMode: 'typeahead', + searchPlaceholder: 'Search kinds...', + }, + reasons: { + id: 'reasons', + label: 'Reason', + inputMode: 'typeahead', + searchPlaceholder: 'Search reasons...', + }, + namespaces: { + id: 'namespaces', + label: 'Namespace', + inputMode: 'typeahead', + searchPlaceholder: 'Search namespaces...', + }, + sourceComponents: { + id: 'sourceComponents', + label: 'Source', + inputMode: 'typeahead', + searchPlaceholder: 'Search sources...', + }, + involvedName: { + id: 'involvedName', + label: 'Resource Name', + inputMode: 'text', + placeholder: 'Enter resource name...', + }, +}; + +/** + * Helper function to convert ISO string to datetime-local format + */ +const formatDatetimeLocal = (isoString: string): string => { + const date = new Date(isoString); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day}T${hours}:${minutes}`; +}; + +/** + * Check if the current time range matches a preset + */ +const getSelectedPreset = (timeRange: TimeRange): string => { + const preset = TIME_PRESETS.find((p) => timeRange.start === p.key); + return preset ? preset.key : 'custom'; +}; + +/** + * EventsFeedFilters provides filter controls for the events feed + */ +export function EventsFeedFilters({ + client, + filters, + timeRange, + onFiltersChange, + onTimeRangeChange, + disabled = false, + hiddenFilters = [], + className = '', + namespace, +}: EventsFeedFiltersProps) { + const { involvedKinds, reasons, namespaces, sourceComponents, error: facetsError } = useEventFacets(client, timeRange, filters); + + // Log facets error for debugging + if (facetsError) { + console.error('Failed to load event facets:', facetsError); + } + + // Track which filter was just added to auto-open it + const [pendingFilter, setPendingFilter] = useState(null); + + // Custom time range state + const selectedPreset = getSelectedPreset(timeRange); + const [customStart, setCustomStart] = useState(() => { + if (selectedPreset === 'custom') { + return formatDatetimeLocal(timeRange.start); + } + return formatDatetimeLocal(formatISO(subDays(new Date(), 1))); + }); + const [customEnd, setCustomEnd] = useState(() => { + if (selectedPreset === 'custom' && timeRange.end) { + return formatDatetimeLocal(timeRange.end); + } + return formatDatetimeLocal(formatISO(new Date())); + }); + + // Handle event type change + const handleEventTypeChange = useCallback( + (value: EventTypeOption) => { + onFiltersChange({ + ...filters, + eventType: value === 'all' ? undefined : value, + }); + }, + [filters, onFiltersChange] + ); + + // Handle time range preset selection + const handleTimePresetSelect = useCallback( + (presetKey: string) => { + onTimeRangeChange({ + start: presetKey, + end: undefined, + }); + }, + [onTimeRangeChange] + ); + + // Handle custom time range apply + const handleCustomRangeApply = useCallback( + (start: string, end: string) => { + setCustomStart(start); + setCustomEnd(end); + onTimeRangeChange({ + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + }); + }, + [onTimeRangeChange] + ); + + // Get display label for time range + const getTimeRangeLabel = () => { + const preset = TIME_PRESETS.find((p) => p.key === selectedPreset); + if (preset) return preset.label; + if (selectedPreset === 'custom' && timeRange.start && timeRange.end) { + const start = new Date(timeRange.start); + const end = new Date(timeRange.end); + return `${start.toLocaleDateString()} - ${end.toLocaleDateString()}`; + } + return 'Select time range'; + }; + + // Get current event type value for toggle + const eventTypeValue: EventTypeOption = filters.eventType || 'all'; + + // Determine which filters are currently active (have values) and not hidden + const filtersWithValues: FilterId[] = []; + if (filters.involvedKinds && filters.involvedKinds.length > 0 && !hiddenFilters.includes('involvedKinds')) filtersWithValues.push('involvedKinds'); + if (filters.reasons && filters.reasons.length > 0 && !hiddenFilters.includes('reasons')) filtersWithValues.push('reasons'); + if (!namespace && filters.namespaces && filters.namespaces.length > 0 && !hiddenFilters.includes('namespaces')) filtersWithValues.push('namespaces'); + if (filters.sourceComponents && filters.sourceComponents.length > 0 && !hiddenFilters.includes('sourceComponents')) filtersWithValues.push('sourceComponents'); + if (filters.involvedName && !hiddenFilters.includes('involvedName')) filtersWithValues.push('involvedName'); + + // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters + const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) + ? [...filtersWithValues, pendingFilter] + : filtersWithValues; + + // Clear pending filter when filter values change (user selected something) + useEffect(() => { + if (pendingFilter && filtersWithValues.includes(pendingFilter)) { + // Filter now has values, clear pending state + setPendingFilter(null); + } + }, [pendingFilter, filtersWithValues]); + + // Build available filters list (exclude namespace if scoped, exclude hidden filters) + const availableFilters: FilterOption[] = [ + { id: 'involvedKinds', label: 'Kind' }, + { id: 'reasons', label: 'Reason' }, + ...(namespace ? [] : [{ id: 'namespaces' as const, label: 'Namespace' }]), + { id: 'sourceComponents', label: 'Source' }, + { id: 'involvedName', label: 'Resource Name' }, + ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); + + // Handle adding a filter + const handleAddFilter = useCallback((filterId: string) => { + setPendingFilter(filterId as FilterId); + }, []); + + // Handle popover close - clear pending filter if no values were selected + const handlePopoverClose = useCallback( + (filterId: FilterId) => { + if (pendingFilter === filterId) { + const hasValues = (() => { + const value = filters[filterId]; + if (filterId === 'involvedName') return !!value; + return Array.isArray(value) && value.length > 0; + })(); + if (!hasValues) { + setPendingFilter(null); + } + } + }, + [pendingFilter, filters] + ); + + // Handle filter value changes + const handleFilterChange = useCallback( + (filterId: FilterId, values: string[]) => { + onFiltersChange({ + ...filters, + [filterId]: values.length > 0 ? values : undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Handle filter clear + const handleFilterClear = useCallback( + (filterId: FilterId) => { + onFiltersChange({ + ...filters, + [filterId]: undefined, + }); + }, + [filters, onFiltersChange] + ); + + // Get options for a specific filter + const getFilterOptions = (filterId: FilterId) => { + switch (filterId) { + case 'involvedKinds': + return involvedKinds + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'reasons': + return reasons + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'namespaces': + return namespaces + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + case 'sourceComponents': + return sourceComponents + .filter((facet) => facet.value) + .map((facet) => ({ + value: facet.value, + label: facet.value, + count: facet.count, + })); + default: + return []; + } + }; + + // Get values for a specific filter + const getFilterValues = (filterId: FilterId): string[] => { + const value = filters[filterId]; + if (filterId === 'involvedName') { + return value ? [value as string] : []; + } + return (value as string[] | undefined) || []; + }; + + // Handle search input change with debouncing + const handleSearchChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + onFiltersChange({ + ...filters, + search: value || undefined, + }); + }, + [filters, onFiltersChange] + ); + + return ( +
+
+ {/* Event Type Toggle */} + {!hiddenFilters.includes('eventType') && ( + + )} + + {/* Search Input */} +
+ + +
+ + {/* Active Filter Chips */} + {activeFilterIds.map((filterId) => { + const config = FILTER_CONFIGS[filterId]; + return ( + handleFilterChange(filterId, values)} + onClear={() => handleFilterClear(filterId)} + onPopoverClose={() => handlePopoverClose(filterId)} + inputMode={config.inputMode} + placeholder={config.placeholder} + searchPlaceholder={config.searchPlaceholder} + autoOpen={pendingFilter === filterId} + disabled={disabled} + /> + ); + })} + + {/* Add Filter Dropdown */} + 0} + disabled={disabled} + /> + + {/* Spacer */} +
+ + {/* Time Range Dropdown */} + +
+
+ ); +} diff --git a/ui/src/components/FilterBuilder.tsx b/ui/src/components/FilterBuilder.tsx index 8fb7136e..3a4ed96a 100644 --- a/ui/src/components/FilterBuilder.tsx +++ b/ui/src/components/FilterBuilder.tsx @@ -1,184 +1,184 @@ -import { useState } from 'react'; -import type { AuditLogQuerySpec } from '../types'; -import { FILTER_FIELDS } from '../types'; -import { Button } from './ui/button'; -import { Card, CardContent, CardHeader } from './ui/card'; -import { Input } from './ui/input'; -import { Label } from './ui/label'; -import { Separator } from './ui/separator'; -import { Textarea } from './ui/textarea'; - -export interface FilterBuilderProps { - onFilterChange: (spec: AuditLogQuerySpec) => void; - initialFilter?: string; - initialLimit?: number; - className?: string; -} - -/** - * FilterBuilder component for constructing CEL filter expressions - */ -export function FilterBuilder({ - onFilterChange, - initialFilter = '', - initialLimit = 100, - className = '', -}: FilterBuilderProps) { - const [filter, setFilter] = useState(initialFilter); - const [limit, setLimit] = useState(initialLimit); - const [showHelp, setShowHelp] = useState(false); - - const handleFilterChange = (newFilter: string) => { - setFilter(newFilter); - onFilterChange({ filter: newFilter, limit }); - }; - - const handleLimitChange = (newLimit: number) => { - const validLimit = Math.min(Math.max(1, newLimit), 1000); - setLimit(validLimit); - onFilterChange({ filter, limit: validLimit }); - }; - - const insertExample = (example: string) => { - const newFilter = filter ? `${filter} && ${example}` : example; - handleFilterChange(newFilter); - }; - - return ( - - -
-

Build Your Query

- -
-
- - - - - {showHelp && ( -
-

Available Filter Fields

-
- {FILTER_FIELDS.map((field) => ( -
-
- {field.name} - {field.type} -
-

{field.description}

- {field.examples && field.examples.length > 0 && ( -
- Examples: - {field.examples.map((example, idx) => ( -
- - {example} - - -
- ))} -
- )} -
- ))} -
- -
-

Common Operators

-
    -
  • == - Equals
  • -
  • != - Not equals
  • -
  • && - And
  • -
  • || - Or
  • -
  • in - In list (e.g., verb in ["create", "delete"])
  • -
  • .startsWith() - String starts with
  • -
  • .contains() - String contains
  • -
  • timestamp() - Parse timestamp (e.g., timestamp("2024-01-01T00:00:00Z"))
  • -
-
-
- )} - -
- -