diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 00000000..ad8395e6 --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,67 @@ +# Git +.git +.gitignore + +# Documentation +README.md +CODE_OF_CONDUCT.md +OWNERS +*.md +*.excalidraw + +# Development files +.env* +.cursor/ +.test/ +.vscode/ +.idea/ + +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Build outputs +dist/ +build/ +*.tgz +*.tar.gz + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.log + +# IDE files +*.swp +*.swo +*~ + +# Test files +coverage/ +*.test +*.spec.js +*.spec.ts + +# Backend specific +backend/vendor/ +backend/*.test +backend/coverage.out + +# Makefile and scripts +Makefile +*.sh + +# License +LICENSE \ No newline at end of file diff --git a/dashboard/.env.development b/dashboard/.env.development new file mode 100644 index 00000000..53ec19ca --- /dev/null +++ b/dashboard/.env.development @@ -0,0 +1,3 @@ +# Development environment settings +VITE_API_BASE= +VITE_USE_REAL_API=true diff --git a/dashboard/.env.production b/dashboard/.env.production new file mode 100644 index 00000000..2c39dd9a --- /dev/null +++ b/dashboard/.env.production @@ -0,0 +1,3 @@ +# Production environment settings +VITE_API_BASE= +VITE_USE_REAL_API=true diff --git a/dashboard/.gitignore b/dashboard/.gitignore new file mode 100644 index 00000000..94109093 --- /dev/null +++ b/dashboard/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +*.kubeconfig \ No newline at end of file diff --git a/dashboard/CODE_OF_CONDUCT.md b/dashboard/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f9c31a10 --- /dev/null +++ b/dashboard/CODE_OF_CONDUCT.md @@ -0,0 +1,44 @@ +# CNCF Code of Conduct + +As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect and consider the needs of everyone and be respectful at all times. + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, political ideology, race, religion, or sexual orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive feedback +- Showing empathy toward other community members +- Focusing on what is best for the community as a whole + +Examples of unacceptable behavior by participants include: + +- Harassing language or imagery in public or private messages +- Personal insults, insults based on identity or background, and unwelcome sexual attention +- Trolling, insulting/derogatory comments, and direct or veiled threats +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all project spaces, including code repositories, mailing lists, chat channels, and issue trackers, as well as at in-person and virtual events related to the project. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [INSERT EMAIL ADDRESS]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter. + +Instances of unacceptable behavior may also be reported to the CNCF Code of Conduct Committee at conduct@cncf.io. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html, and the CNCF Code of Conduct, available at https://github.com/cncf/foundation/blob/master/code-of-conduct.md. diff --git a/dashboard/Dockerfile.api b/dashboard/Dockerfile.api new file mode 100644 index 00000000..61129844 --- /dev/null +++ b/dashboard/Dockerfile.api @@ -0,0 +1,48 @@ +# APISERVER Dockerfile for OCM Dashboard API +FROM golang:1.24-alpine AS builder + +# Install git and ca-certificates (needed for go modules and HTTPS) +RUN apk add --no-cache git ca-certificates + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY apiserver/go.mod apiserver/go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy apiserver source +COPY apiserver/ ./ + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags='-w -s -extldflags "-static"' \ + -a -installsuffix cgo \ + -o apiserver \ + main.go + +# Final runtime image +FROM gcr.io/distroless/static:nonroot + +# Copy ca-certificates from builder +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ + +# Copy apiserver binary +COPY --from=builder /app/apiserver /app/apiserver + +# Set working directory +WORKDIR /app + +# Use non-root user +USER nonroot:nonroot + +# Expose port +EXPOSE 8080 + +# Set environment variables +ENV GIN_MODE=release + +# Run the application +ENTRYPOINT ["/app/apiserver"] \ No newline at end of file diff --git a/dashboard/Dockerfile.ui b/dashboard/Dockerfile.ui new file mode 100644 index 00000000..09821bb1 --- /dev/null +++ b/dashboard/Dockerfile.ui @@ -0,0 +1,54 @@ +# Frontend Dockerfile for OCM Dashboard UI +FROM node:22-alpine AS builder + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY pnpm-lock.yaml ./ + +# Install dependencies +RUN npm install -g pnpm && pnpm install --frozen-lockfile + +# Copy source code +COPY . . + +# Build frontend +RUN pnpm run build + +# Production stage with golang and gin +FROM golang:1.24-alpine AS server + +# Set working directory +WORKDIR /app + +# Copy uiserver directory +COPY uiserver/ ./ + +# Download Go dependencies +RUN go mod download + +# Build Go server +RUN go build -o uiserver uiserver.go + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates + +# Set working directory +WORKDIR /app + +# Copy built assets from builder stage +COPY --from=builder /app/dist ./dist + +# Copy Go server binary from server stage +COPY --from=server /app/uiserver ./ + +# Expose port 3000 +EXPOSE 3000 + +# Start the Go server +CMD ["./uiserver"] \ No newline at end of file diff --git a/dashboard/LICENSE b/dashboard/LICENSE new file mode 100644 index 00000000..e18ad420 --- /dev/null +++ b/dashboard/LICENSE @@ -0,0 +1,121 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + +2. Grant of Patent License. + Subject to the terms and conditions of this License, each Contributor + hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, + royalty-free, irrevocable (except as stated in this section) patent license + to make, have made, use, offer to sell, sell, import, and otherwise transfer + the Work, where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their Contribution(s) + alone or by combination of their Contribution(s) with the Work to which such + Contribution(s) was submitted. If You institute patent litigation against + any entity (including a cross-claim or counterclaim in a lawsuit) alleging + that the Work or a Contribution incorporated within the Work constitutes + direct or contributory patent infringement, then any patent licenses granted + to You under this License for that Work shall terminate as of the date such + litigation is filed. + +3. Redistribution. + You may reproduce and distribute copies of the Work or Derivative + Works thereof in any medium, with or without modifications, and in + Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative + Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + (d) If the Work includes a file named "NOTICE" with a notice + text, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be + construed as modifying the License. + +4. Submission of Contributions. + Unless You explicitly state otherwise, any Contribution intentionally + submitted for inclusion in the Work by You to the Licensor shall be + under the terms and conditions of this License, without any + additional terms or conditions. Notwithstanding the above, + nothing herein shall supersede or modify the terms of any separate + license agreement between You and the Licensor regarding such + Contributions. + +5. Trademarks. + This License does not grant permission to use the trade names, + trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing + the origin of the Work and reproducing the content of the NOTICE + file. + +6. Disclaimer of Warranty. + Unless required by applicable law or agreed to in writing, Licensor + provides the Work (and each Contributor provides its Contributions) + on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, + either express or implied, including, but not limited to, any + warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, + or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible + for determining the appropriateness of using or redistributing + the Work and assume any risks associated with Your exercise + of permissions under this License. + +7. Limitation of Liability. + In no event and under no legal theory, whether in tort (including + negligence), contract, or otherwise, unless required by applicable law + (such as deliberate and grossly negligent acts) or agreed to in writing, + shall any Contributor be liable to You for damages, including any + direct, indirect, special, incidental, or consequential damages of + any character arising as a result of this License or out of the use + or inability to use the Work (including but not limited to damages + for loss of goodwill, work stoppage, computer failure or malfunction, + or any and all other commercial damages or losses), even if such + Contributor has been advised of the possibility of such damages. + +8. Accepting Warranty or Additional Liability. + While redistributing the Work or Derivative Works thereof, + You may choose to offer, and charge a fee for, acceptance of + support, warranty, indemnity, or other liability obligations and/or + rights consistent with this License. However, in such cases, + You must make it absolutely clear that any such warranty, + indemnity, or other such obligations are offered by You alone, + and You hereby disclaim any such warranty, indemnity, or other + liability obligations to the extent permitted by law. You may also + include additional disclaimers of warranty and limitation of + liability provisions specific to any jurisdiction. + +END OF TERMS AND CONDITIONS + +Copyright 2025 Open Cluster Management Community + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/dashboard/Makefile b/dashboard/Makefile new file mode 100644 index 00000000..dceb189e --- /dev/null +++ b/dashboard/Makefile @@ -0,0 +1,168 @@ +# Default values +API_IMAGE_NAME ?= dashboard-api +UI_IMAGE_NAME ?= dashboard-ui +IMAGE_TAG ?= latest +REGISTRY ?= quay.io/open-cluster-management +PLATFORMS ?= linux/amd64,linux/arm64 + +# Full image names +API_FULL_IMAGE_NAME = $(REGISTRY)/$(API_IMAGE_NAME):$(IMAGE_TAG) +UI_FULL_IMAGE_NAME = $(REGISTRY)/$(UI_IMAGE_NAME):$(IMAGE_TAG) + +.PHONY: dev-ui dev-uiserver dev-apiserver dev-apiserver-real build-ui build-uiserver build-apiserver build docker-build-api docker-push-api docker-build-push-api clean + +# Development targets +dev-ui: + cd . && npm run dev + +dev-uiserver: + @echo "Building JS code first..." + npm run build + @echo "Starting UI GIN server..." + cd uiserver && go run uiserver.go + +dev-apiserver: + cd apiserver && chmod +x run-dev.sh && ./run-dev.sh + +dev-apiserver-real: + cd apiserver && go run main.go + +# Build targets +build-ui: + cd . && npm run build + +build-uiserver: + cd uiserver && go build -o uiserver + +build-apiserver: + cd apiserver && go build -o apiserver + +build: build-ui build-uiserver build-apiserver + +# Docker build targets for API (multi-arch, no load) +docker-build-api: + @echo "Building API Docker image: $(API_FULL_IMAGE_NAME)" + docker buildx build \ + --platform $(PLATFORMS) \ + -f Dockerfile.api \ + -t $(API_FULL_IMAGE_NAME) \ + . + +docker-push-api: + @echo "Pushing API Docker image: $(API_FULL_IMAGE_NAME)" + docker push $(API_FULL_IMAGE_NAME) + +docker-build-push-api: + @echo "Building and pushing API multi-arch Docker image: $(API_FULL_IMAGE_NAME)" + docker buildx build \ + --platform $(PLATFORMS) \ + -f Dockerfile.api \ + -t $(API_FULL_IMAGE_NAME) \ + --push \ + . + +# Docker build targets for UI (multi-arch, no load) +docker-build-ui: + @echo "Building UI Docker image: $(UI_FULL_IMAGE_NAME)" + docker buildx build \ + --platform $(PLATFORMS) \ + -f Dockerfile.ui \ + -t $(UI_FULL_IMAGE_NAME) \ + . + +docker-push-ui: + @echo "Pushing UI Docker image: $(UI_FULL_IMAGE_NAME)" + docker push $(UI_FULL_IMAGE_NAME) + +docker-build-push-ui: + @echo "Building and pushing UI multi-arch Docker image: $(UI_FULL_IMAGE_NAME)" + docker buildx build \ + --platform $(PLATFORMS) \ + -f Dockerfile.ui \ + -t $(UI_FULL_IMAGE_NAME) \ + --push \ + . + +# Build both images +docker-build: docker-build-api docker-build-ui + +# Push both images +docker-push: docker-push-api docker-push-ui + +# Build and push both images +docker-build-push: docker-build-push-api docker-build-push-ui + +# Build for local development (single arch) +docker-build-local-api: + @echo "Building local API Docker image: $(API_IMAGE_NAME):$(IMAGE_TAG)" + docker buildx build \ + -f Dockerfile.api \ + -t $(API_IMAGE_NAME):$(IMAGE_TAG) \ + --load \ + . + +docker-build-local-ui: + @echo "Building local UI Docker image: $(UI_IMAGE_NAME):$(IMAGE_TAG)" + docker buildx build \ + -f Dockerfile.ui \ + -t $(UI_IMAGE_NAME):$(IMAGE_TAG) \ + --load \ + . + +docker-build-local: docker-build-local-api docker-build-local-ui + +# Legacy docker target for backward compatibility +docker: docker-build-local + +# Clean up +clean: + rm -rf dist + rm -rf apiserver/static + rm -f apiserver/apiserver + rm -f uiserver/uiserver + +# Add target to use debug script +debug-apiserver: + cd apiserver && chmod +x debug.sh && ./debug.sh + +# Setup buildx builder for multi-arch builds +setup-buildx: + docker buildx create --name ocm-builder --use || docker buildx use ocm-builder + docker buildx inspect --bootstrap + +# Default target +all: build + +test-frontend: + npm run test + +test-apiserver: + cd apiserver && go test ./... + +test-uiserver: + cd uiserver && go test ./... + +test: test-frontend test-apiserver test-uiserver + @echo "All tests passed!" + +lint: + npm run lint + cd apiserver && go vet ./... + cd uiserver && go vet ./... + +# Test the UI server functionality +test-uiserver-functionality: + @echo "Testing UI server functionality..." + @echo "Building frontend..." + @npm run build > /dev/null 2>&1 + @echo "Starting UI server in background..." + @cd uiserver && go run uiserver.go & echo $$! > /tmp/uiserver.pid + @sleep 3 + @echo "Testing endpoints..." + @curl -s http://localhost:3000/health | grep -q "healthy" && echo "✅ Health endpoint: OK" || echo "❌ Health endpoint: FAILED" + @curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ | grep -q "200" && echo "✅ Main page: OK" || echo "❌ Main page: FAILED" + @curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/assets/index-B4HUQdL0.css | grep -E "(200|404)" > /dev/null && echo "✅ Assets: Available" || echo "❌ Assets: FAILED" + @echo "Stopping UI server..." + @kill `cat /tmp/uiserver.pid` 2>/dev/null || true + @rm -f /tmp/uiserver.pid + @echo "✅ UI server test completed!" \ No newline at end of file diff --git a/dashboard/OWNERS b/dashboard/OWNERS new file mode 100644 index 00000000..62ccf232 --- /dev/null +++ b/dashboard/OWNERS @@ -0,0 +1,5 @@ +approvers: + - xuezhaojun + +reviewers: + - xuezhaojun \ No newline at end of file diff --git a/dashboard/README.md b/dashboard/README.md new file mode 100644 index 00000000..0c630801 --- /dev/null +++ b/dashboard/README.md @@ -0,0 +1,487 @@ +# OCM Dashboard + +![Node.js](https://img.shields.io/badge/node-%3E%3D22.0.0-green) +![Go](https://img.shields.io/badge/go-%3E%3D1.23-blue) +![License](https://img.shields.io/badge/license-Apache%202.0-blue) + +--- + +## 📑 Table of Contents + +- [Project Overview](#project-overview) +- [Architecture](#architecture) + - [Frontend Components](#frontend-components) + - [Backend Components](#backend-components) +- [Current Features](#current-features) +- [Setup & Development](#setup--development) + - [Prerequisites](#prerequisites) + - [Frontend Development](#frontend-development) + - [Backend Development](#backend-development) + - [Connecting Frontend to Backend](#connecting-frontend-to-backend) +- [Testing](#testing) +- [Building for Production](#building-for-production) + - [Build Application](#build-application) + - [Docker Images](#docker-images) + - [Available Make Targets](#available-make-targets) +- [Deployment](#deployment) + - [Helm Chart Deployment](#helm-chart-deployment) +- [Configuration](#configuration) +- [RBAC Requirements (For Backend)](#rbac-requirements-for-backend) +- [Next Steps](#next-steps) +- [License](#license) + +--- + +## Project Overview + +A dashboard for displaying and monitoring Open Cluster Management (OCM) clusters, placements, cluster sets, manifest works, and addons. + +![OCM Dashboard](./public/images/demo.gif) + +--- + +## Architecture + +The OCM Dashboard follows a modern architecture pattern for Kubernetes dashboards: + +- **Frontend**: React + TypeScript SPA with Material UI and real-time updates +- **Backend**: Go API service that connects to the Kubernetes API for OCM resources + +### Frontend Components + +- **Authentication**: Bearer token authentication (JWT) stored in localStorage +- **Overview Page**: High-level KPIs for clusters, cluster sets, and placements +- **Cluster List & Detail**: Table view and detail drawer for clusters, including status, version, claims, and addons +- **Placement List & Detail**: Table view and detail drawer for placements, including status, predicates, and decisions +- **ClusterSet List & Detail**: Table view and detail drawer for ManagedClusterSets, including cluster and binding counts +- **ManifestWorks List**: View manifest works for clusters, including manifest and condition details +- **Addons List**: View managed cluster addons, including status, registrations, and supported configs +- **Login Page**: Token-based login with development mode support +- **Layout**: Responsive layout with navigation drawer and app bar +- **API Service Layer**: Abstraction for backend communication using `fetch` + +### Backend Components + +- **API Server**: Go service built with Gin, providing endpoints for OCM resources: + - `GET /api/clusters` - List all ManagedClusters + - `GET /api/clusters/:name` - Get details for a specific ManagedCluster + - `GET /api/clustersets` - List all ManagedClusterSets + - `GET /api/clustersets/:name` - Get details for a specific ManagedClusterSet + - `GET /api/clustersetbindings` - List all ManagedClusterSetBindings + - `GET /api/clustersetbindings/:namespace` - List bindings in a namespace + - `GET /api/clustersetbindings/:namespace/:name` - Get a specific binding + - `GET /api/placements` - List all Placements + - `GET /api/placements/:namespace` - List Placements in a namespace + - `GET /api/placements/:namespace/:name` - Get a specific Placement + - `GET /api/placements/:namespace/:name/decisions` - Get PlacementDecisions for a Placement + - `GET /api/manifestworks/:namespace` - List ManifestWorks in a namespace (cluster) + - `GET /api/manifestworks/:namespace/:name` - Get a specific ManifestWork + - `GET /api/addons/:name` - List all Addons for a cluster + - `GET /api/addons/:name/:addonName` - Get a specific Addon for a cluster + - `GET /api/stream/clusters` - SSE endpoint for real-time ManagedCluster updates +- **Authentication**: Basic authorization header check. TokenReview validation is a TODO. Can be bypassed with `DASHBOARD_BYPASS_AUTH=true`. +- **Kubernetes Client**: Uses `client-go` to interact with the Kubernetes API for OCM resources (ManagedCluster, ManagedClusterSet, Placement, ManifestWork, Addon, etc.) +- **Mock Data Mode**: Supports running with mock data for development via `DASHBOARD_USE_MOCK=true`. + +--- + +## Current Features + +**Frontend:** + +- Read-only view of clusters, placements, cluster sets, manifest works, and addons +- Table and detail views for all major OCM resources +- Real-time cluster status updates via SSE +- Authentication flow with token support (bearer token in localStorage) +- Responsive UI with Material UI components +- Overview dashboard with KPIs +- Error handling and loading states + +**Backend:** + +- API endpoints for all major OCM resources (Clusters, ClusterSets, ClusterSetBindings, Placements, ManifestWorks, Addons) +- Placement and PlacementDecision support +- ManifestWork and Addon support +- SSE endpoint for streaming cluster updates +- Kubernetes client integration using `client-go` +- Support for in-cluster and out-of-cluster kubeconfig +- CORS configured for broad access (e.g. `*`) +- Debug mode (`DASHBOARD_DEBUG=true`) and mock data mode (`DASHBOARD_USE_MOCK=true`) + +--- + +## Setup & Development + +### Prerequisites + +- Node.js 18+ and npm/pnpm +- Go 1.22+ (for backend development) +- Docker with buildx support (for building images) +- Access to a Kubernetes cluster with OCM installed (for backend integration) +- Make (for using the Makefile commands) + +### Frontend Development + +```bash +# Install dependencies and run development server +npm install +npm run dev + +# Or use make: +make dev-ui +``` + +Open your browser at the URL shown in the terminal (usually http://localhost:5173) + +### Backend Development + +```bash +# Run API server with mock data (recommended for development) +make dev-apiserver + +# Run API server with real Kubernetes connection +make dev-apiserver-real + +# Run API server with debugger +make debug-apiserver + +# Run UI server (serves built frontend) +make dev-uiserver +``` + +The `dev-apiserver` target runs the debug script which sets appropriate environment variables for development. +You can modify the `debug.sh` script or set environment variables directly to change behavior (e.g., `KUBECONFIG` path, `PORT`). + +### Connecting Frontend to Backend + +The frontend (`src/api/utils.ts`) is configured to connect to the backend API, typically running on `http://localhost:8080`. Ensure the backend server is running when developing the frontend. +The `VITE_API_BASE_URL` in `.env.development` (for frontend) should match the backend server address. + +--- + +## Testing + +Run tests for all components: + +```bash +# Run all tests (frontend, API server, and UI server) +make test + +# Run frontend tests only +make test-frontend + +# Run API server tests only +make test-apiserver + +# Run UI server tests only +make test-uiserver + +# Run linting for all components +make lint + +# Test UI server functionality +make test-uiserver-functionality +``` + +--- + +## Building for Production + +### Build Application + +```bash +# Build all components (UI, UI server, and API server) +make build + +# Build UI only +make build-ui + +# Build UI server only +make build-uiserver + +# Build API server only +make build-apiserver +``` + +### Docker Images + +The project builds two separate Docker images: + +- **API Image**: `dashboard-api` (Go backend) +- **UI Image**: `dashboard-ui` (React frontend with nginx) + +#### Configuration Variables + +You can customize the build process using these variables: + +- `API_IMAGE_NAME`: API image name (default: `dashboard-api`) +- `UI_IMAGE_NAME`: UI image name (default: `dashboard-ui`) +- `IMAGE_TAG`: Docker image tag (default: `latest`) +- `REGISTRY`: Docker registry (default: `quay.io/open-cluster-management`) +- `PLATFORMS`: Target platforms (default: `linux/amd64,linux/arm64`) + +#### Using Make (Recommended) + +```bash +# Setup Docker buildx for multi-arch builds (first time only) +make setup-buildx + +# Build both images for local testing (single architecture) +make docker-build-local + +# Build both images for production (multi-architecture) +make docker-build + +# Build and push both images +make docker-build-push + +# Build specific components +make docker-build-api +make docker-build-ui + +# Build with custom configuration +make docker-build-push IMAGE_TAG=v1.0.0 +make docker-build-push REGISTRY=myregistry.io/myorg IMAGE_TAG=dev +make docker-build PLATFORMS=linux/amd64 +``` + +#### Individual Image Operations + +```bash +# API Image +make docker-build-api # Build API image +make docker-push-api # Push API image +make docker-build-push-api # Build and push API image + +# UI Image +make docker-build-ui # Build UI image +make docker-push-ui # Push UI image +make docker-build-push-ui # Build and push UI image +``` + +#### Using Docker Directly + +```bash +# Build API image +docker buildx build -f Dockerfile.api -t dashboard-api:latest --load . + +# Build UI image +docker buildx build -f Dockerfile.ui -t dashboard-ui:latest --load . + +# Build and push multi-architecture images +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -f Dockerfile.api \ + -t quay.io/open-cluster-management/dashboard-api:latest \ + --push . + +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -f Dockerfile.ui \ + -t quay.io/open-cluster-management/dashboard-ui:latest \ + --push . +``` + +### Available Make Targets + +The following make targets are available: + +**Development Targets:** + +- `dev-ui`: Run UI in development mode +- `dev-uiserver`: Run UI server (serves built frontend) +- `dev-apiserver`: Run API server with development settings +- `dev-apiserver-real`: Run API server with real Kubernetes connection +- `debug-apiserver`: Run API server in debug mode + +**Build Targets:** + +- `build`: Build all components (UI, UI server, and API server) +- `build-ui`: Build UI only +- `build-uiserver`: Build UI server only +- `build-apiserver`: Build API server only + +**Docker Targets:** + +- `docker-build`: Build both API and UI Docker images +- `docker-build-api`: Build API Docker image +- `docker-build-ui`: Build UI Docker image +- `docker-push`: Push both API and UI Docker images +- `docker-push-api`: Push API Docker image only +- `docker-push-ui`: Push UI Docker image only +- `docker-build-push`: Build and push both images +- `docker-build-push-api`: Build and push API image +- `docker-build-push-ui`: Build and push UI image +- `docker-build-local`: Build both images for local use +- `docker-build-local-api`: Build API image for local use +- `docker-build-local-ui`: Build UI image for local use + +**Test Targets:** + +- `test`: Run all tests +- `test-frontend`: Run frontend tests +- `test-apiserver`: Run API server tests +- `test-uiserver`: Run UI server tests +- `test-uiserver-functionality`: Test UI server functionality +- `lint`: Run linters for all components + +**Other Targets:** + +- `setup-buildx`: Setup Docker buildx for multi-arch builds +- `clean`: Clean build artifacts +- `all`: Default target (alias for `build`) + +--- + +## Deployment + +### Helm Chart Deployment + +Deploy using Helm chart: + +```bash +# Add OCM Helm repository +helm repo add ocm https://open-cluster-management.io/helm-charts +helm repo update + +# Install OCM Dashboard +helm install ocm-dashboard ocm/ocm-dashboard \ + --namespace ocm-dashboard \ + --create-namespace + +# Install with custom values +helm install ocm-dashboard ocm/ocm-dashboard \ + --namespace ocm-dashboard \ + --create-namespace \ + --set image.tag=latest \ + --set dashboard.env.DASHBOARD_BYPASS_AUTH=true + + +# Install with custom image +helm install ocm-dashboard ./charts/ocm-dashboard \ + --set api.image.registry=quay.io \ + --set api.image.repository=zhaoxue/dashboard-api \ + --set api.image.tag=latest \ + --set api.image.pullPolicy=Always \ + --set ui.image.registry=quay.io \ + --set ui.image.repository=zhaoxue/dashboard-ui \ + --set ui.image.tag=latest \ + --set ui.image.pullPolicy=Always \ + --namespace open-cluster-management-dashboard \ + --create-namespace + +# Upgrade existing installation +helm upgrade ocm-dashboard ocm/ocm-dashboard \ + --namespace ocm-dashboard + +# Access via port-forward +kubectl port-forward -n open-cluster-management-dashboard service/ocm-dashboard 3000:80 +``` + +For development with local chart: + +```bash +# Install from local chart +helm install ocm-dashboard ./charts/ocm-dashboard \ + --namespace ocm-dashboard \ + --create-namespace + +# Install with custom values file +helm install ocm-dashboard ./charts/ocm-dashboard \ + --namespace ocm-dashboard \ + --create-namespace \ + --values my-values.yaml +``` + +--- + +## Configuration + +### Environment Variables + +**Backend Configuration:** + +- `DASHBOARD_USE_MOCK`: Enable mock data mode (default: `false`) +- `DASHBOARD_DEBUG`: Enable debug logging (default: `false`) +- `DASHBOARD_BYPASS_AUTH`: Bypass authentication (default: `false`) +- `PORT`: Server port (default: `8080`) +- `KUBECONFIG`: Path to kubeconfig file (for out-of-cluster access) + +**Frontend Configuration:** + +- `VITE_API_BASE_URL`: Backend API URL (default: `http://localhost:8080`) + +--- + +## RBAC Requirements (For Backend) + +For the backend to function correctly, it will need RBAC permissions to: + +1. List, get, and watch all OCM resources (ManagedCluster, ManagedClusterSet, ManagedClusterSetBinding, Placement, ManifestWork, Addon, etc.) +2. Perform token reviews for authentication + +
+Example RBAC configuration + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: ocm-dashboard + namespace: ocm-dashboard +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: ocm-dashboard-reader +rules: + - apiGroups: ["cluster.open-cluster-management.io"] + resources: + [ + "managedclusters", + "managedclustersets", + "managedclustersetbindings", + "placements", + "placementdecisions", + "manifestworks", + "managedclusteraddons", + ] + verbs: ["get", "list", "watch"] + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: ocm-dashboard-reader-binding +subjects: + - kind: ServiceAccount + name: ocm-dashboard + namespace: ocm-dashboard +roleRef: + kind: ClusterRole + name: ocm-dashboard-reader + apiGroup: rbac.authorization.k8s.io +``` + +
+ +--- + +## Next Steps + +1. Fully implement real-time updates using SSE with actual Kubernetes informers in the backend. +2. Implement robust TokenReview authentication in the backend. +3. Enhance error handling and user feedback in both frontend and backend. +4. Create a Helm chart for deployment. +5. Add comprehensive unit and integration tests for both frontend and backend. +6. Improve UI/UX, potentially adding more visualizations or actions. +7. Add support for more OCM resource types and actions as needed. +8. Optimize frontend testing and mock implementation. + +--- + +## License + +This project is licensed under the Apache License 2.0. diff --git a/dashboard/apiserver/apiserver b/dashboard/apiserver/apiserver new file mode 100755 index 00000000..e999fc29 Binary files /dev/null and b/dashboard/apiserver/apiserver differ diff --git a/dashboard/apiserver/debug.sh b/dashboard/apiserver/debug.sh new file mode 100755 index 00000000..55938e59 --- /dev/null +++ b/dashboard/apiserver/debug.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +echo "===== Kubernetes Client Debug Tool =====" +echo "Checking KUBECONFIG..." + +if [ -z "$KUBECONFIG" ]; then + echo "❌ KUBECONFIG environment variable is not set" + echo "Please set it using: export KUBECONFIG=/path/to/your/kubeconfig" +else + echo "✅ KUBECONFIG = $KUBECONFIG" + + if [ -f "$KUBECONFIG" ]; then + echo "✅ KUBECONFIG file exists" + + # Check file permissions + if [ -r "$KUBECONFIG" ]; then + echo "✅ KUBECONFIG file is readable" + else + echo "❌ KUBECONFIG file is not readable, please check permissions" + echo "Run: chmod 600 $KUBECONFIG" + fi + + # Validate file content + if grep -q "server:" "$KUBECONFIG"; then + echo "✅ KUBECONFIG file contains server configuration" + else + echo "❌ KUBECONFIG file may not contain valid cluster configuration" + fi + else + echo "❌ KUBECONFIG file does not exist: $KUBECONFIG" + fi +fi + +echo "" +echo "Testing Kubernetes connection..." + +# Test if we can get basic information +if command -v kubectl &> /dev/null; then + echo "Attempting to connect to cluster..." + kubectl version --short 2>/dev/null + + if [ $? -eq 0 ]; then + echo "✅ Successfully connected to Kubernetes cluster" + echo "✅ Validating OCM resources..." + + # Check if OCM resources exist + if kubectl api-resources | grep -q "managedclusters"; then + echo "✅ ManagedClusters resource type exists" + echo "Listing ManagedClusters in current cluster:" + kubectl get managedclusters + else + echo "❌ ManagedClusters resource type does not exist, OCM needs to be installed" + fi + else + echo "❌ Cannot connect to Kubernetes cluster" + fi +else + echo "❌ kubectl is not installed, cannot test connection" +fi + +echo "" +echo "===== Environment Variables =====" +echo "Run the following commands to enable debug mode:" +echo "export DASHBOARD_BYPASS_AUTH=true" +echo "export DASHBOARD_DEBUG=true" +echo "" +echo "Run the following command to start the application:" +echo "cd .. && make dev-backend" \ No newline at end of file diff --git a/dashboard/apiserver/go.mod b/dashboard/apiserver/go.mod new file mode 100644 index 00000000..cc98e9de --- /dev/null +++ b/dashboard/apiserver/go.mod @@ -0,0 +1,72 @@ +module open-cluster-management-io/lab/apiserver + +go 1.24.1 + +require ( + github.com/gin-contrib/cors v1.5.0 + github.com/gin-gonic/gin v1.9.1 + k8s.io/apimachinery v0.30.2 + k8s.io/client-go v0.30.2 + open-cluster-management.io/api v0.16.2 +) + +require ( + github.com/bytedance/sonic v1.10.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect + github.com/chenzhuoyu/iasm v0.9.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.15.5 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.5 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.5.0 // indirect + golang.org/x/crypto v0.21.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/term v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + golang.org/x/time v0.3.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.30.2 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect +) diff --git a/dashboard/apiserver/go.sum b/dashboard/apiserver/go.sum new file mode 100644 index 00000000..e1b12afd --- /dev/null +++ b/dashboard/apiserver/go.sum @@ -0,0 +1,212 @@ +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM= +github.com/bytedance/sonic v1.10.1 h1:7a1wuFXL1cMy7a3f7/VFcEtriuXQnUBhtoVfOZiaysc= +github.com/bytedance/sonic v1.10.1/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0= +github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA= +github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo= +github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= +github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24= +github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +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/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= +github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +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/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo/v2 v2.15.0 h1:79HwNRBAZHOEwrczrgSOPy+eFTTlIGELKy5as+ClttY= +github.com/onsi/ginkgo/v2 v2.15.0/go.mod h1:HlxMHtYF57y6Dpf+mc5529KKmSq9h2FpCF+/ZkwUxKM= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +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.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.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y= +golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +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/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= +golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +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.18.0 h1:k8NLag8AGHnn+PHbl7g43CtqZAwG60vZkLqgyZgIHgQ= +golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= +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= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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.30.2 h1:+ZhRj+28QT4UOH+BKznu4CBgPWgkXO7XAvMcMl0qKvI= +k8s.io/api v0.30.2/go.mod h1:ULg5g9JvOev2dG0u2hig4Z7tQ2hHIuS+m8MNZ+X6EmI= +k8s.io/apimachinery v0.30.2 h1:fEMcnBj6qkzzPGSVsAZtQThU62SmQ4ZymlXRC5yFSCg= +k8s.io/apimachinery v0.30.2/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.2 h1:sBIVJdojUNPDU/jObC+18tXWcTJVcwyqS9diGdWHk50= +k8s.io/client-go v0.30.2/go.mod h1:JglKSWULm9xlJLx4KCkfLLQ7XwtlbflV6uFFSHTMgVs= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +open-cluster-management.io/api v0.16.2 h1:JzpJtgp/qJKjDLEO7o7q5eVLxYkfgxhtagJvWFbaNno= +open-cluster-management.io/api v0.16.2/go.mod h1:9erZEWEn4bEqh0nIX2wA7f/s3KCuFycQdBrPrRzi0QM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/dashboard/apiserver/main.go b/dashboard/apiserver/main.go new file mode 100644 index 00000000..8b8a3929 --- /dev/null +++ b/dashboard/apiserver/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "context" + "os" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/server" +) + +func main() { + // Check if debug mode is enabled + debugMode := os.Getenv("DASHBOARD_DEBUG") == "true" + + // Create a context + ctx := context.Background() + + // Initialize Kubernetes client + ocmClient := client.CreateKubernetesClient() + + // Set up and run the server + r := server.SetupServer(ocmClient, ctx, debugMode) + server.RunServer(r) +} diff --git a/dashboard/apiserver/main_test.go b/dashboard/apiserver/main_test.go new file mode 100644 index 00000000..09fb8c09 --- /dev/null +++ b/dashboard/apiserver/main_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMain(t *testing.T) { + tests := []struct { + name string + debugMode string + useMock string + expectPanic bool + }{ + { + name: "debug mode enabled", + debugMode: "true", + useMock: "true", + expectPanic: false, + }, + { + name: "debug mode disabled", + debugMode: "false", + useMock: "true", + expectPanic: false, + }, + { + name: "no environment variables", + debugMode: "", + useMock: "true", + expectPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("DASHBOARD_DEBUG", tt.debugMode) + os.Setenv("DASHBOARD_USE_MOCK", tt.useMock) + defer func() { + os.Unsetenv("DASHBOARD_DEBUG") + os.Unsetenv("DASHBOARD_USE_MOCK") + }() + + if tt.expectPanic { + assert.Panics(t, func() { + main() + }) + } else { + assert.NotPanics(t, func() { + defer func() { + if r := recover(); r != nil { + if r != "test exit" { + panic(r) + } + } + }() + main() + }) + } + }) + } +} diff --git a/dashboard/apiserver/pkg/client/kubernetes.go b/dashboard/apiserver/pkg/client/kubernetes.go new file mode 100644 index 00000000..10b47ba7 --- /dev/null +++ b/dashboard/apiserver/pkg/client/kubernetes.go @@ -0,0 +1,79 @@ +package client + +import ( + "log" + "os" + "path/filepath" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/client-go/util/homedir" +) + +// CreateKubernetesClient initializes a connection to the Kubernetes API +func CreateKubernetesClient() *OCMClient { + // Get kubeconfig + var kubeconfig string + + // Check if running in-cluster + _, inClusterErr := rest.InClusterConfig() + inCluster := inClusterErr == nil + + if !inCluster { + if home := homedir.HomeDir(); home != "" { + kubeconfig = filepath.Join(home, ".kube", "config") + } + } + + // Create kubernetes client + var config *rest.Config + var err error + if inCluster { + // creates the in-cluster config + config, err = rest.InClusterConfig() + if err != nil { + log.Fatalf("Error creating in-cluster config: %v", err) + } + log.Println("Using in-cluster configuration") + } else { + // First try to use the KUBECONFIG environment variable + kubeconfigEnv := os.Getenv("KUBECONFIG") + if kubeconfigEnv != "" { + log.Printf("Using KUBECONFIG from environment: %s", kubeconfigEnv) + config, err = clientcmd.BuildConfigFromFlags("", kubeconfigEnv) + if err != nil { + log.Printf("Error building kubeconfig from KUBECONFIG env: %v", err) + // Fall back to command line flag or default + } + } + + // If KUBECONFIG env var didn't work, try the flag or default path + if config == nil { + log.Printf("Using kubeconfig from flag or default: %s", kubeconfig) + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + // Try the load rules (will check multiple locations) + log.Printf("Trying default client config loading rules") + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{ClusterDefaults: clientcmdapi.Cluster{Server: ""}} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + config, err = kubeConfig.ClientConfig() + if err != nil { + log.Fatalf("Error building kubeconfig using defaults: %v", err) + } + } + } + } + + // Create OCM client + ocmClient, err := CreateOCMClient(config) + if err != nil { + log.Fatalf("Error creating OCM client: %v", err) + } + + // Debug message to verify connection + log.Println("Successfully created Kubernetes client") + + return ocmClient +} diff --git a/dashboard/apiserver/pkg/client/mocks.go b/dashboard/apiserver/pkg/client/mocks.go new file mode 100644 index 00000000..14bfd159 --- /dev/null +++ b/dashboard/apiserver/pkg/client/mocks.go @@ -0,0 +1,125 @@ +package client + +import ( + "context" + + "github.com/stretchr/testify/mock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + addonv1alpha1 "open-cluster-management.io/api/addon/v1alpha1" + clusterv1 "open-cluster-management.io/api/cluster/v1" + clusterv1beta1 "open-cluster-management.io/api/cluster/v1beta1" + clusterv1beta2 "open-cluster-management.io/api/cluster/v1beta2" + workv1 "open-cluster-management.io/api/work/v1" +) + +type MockOCMClient struct { + mock.Mock + Interface dynamic.Interface +} + +type MockClusterV1Interface struct { + mock.Mock +} + +type MockManagedClustersInterface struct { + mock.Mock +} + +type MockManagedClusterSetsInterface struct { + mock.Mock +} + +type MockPlacementsInterface struct { + mock.Mock +} + +type MockPlacementDecisionsInterface struct { + mock.Mock +} + +type MockAddonV1Alpha1Interface struct { + mock.Mock +} + +type MockManagedClusterAddOnsInterface struct { + mock.Mock +} + +type MockWorkV1Interface struct { + mock.Mock +} + +type MockManifestWorksInterface struct { + mock.Mock +} + +func (m *MockClusterV1Interface) ManagedClusters() *MockManagedClustersInterface { + args := m.Called() + return args.Get(0).(*MockManagedClustersInterface) +} + +func (m *MockManagedClustersInterface) List(ctx context.Context, opts metav1.ListOptions) (*clusterv1.ManagedClusterList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*clusterv1.ManagedClusterList), args.Error(1) +} + +func (m *MockManagedClustersInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*clusterv1.ManagedCluster, error) { + args := m.Called(ctx, name, opts) + return args.Get(0).(*clusterv1.ManagedCluster), args.Error(1) +} + +func (m *MockManagedClusterSetsInterface) List(ctx context.Context, opts metav1.ListOptions) (*clusterv1beta2.ManagedClusterSetList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*clusterv1beta2.ManagedClusterSetList), args.Error(1) +} + +func (m *MockManagedClusterSetsInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*clusterv1beta2.ManagedClusterSet, error) { + args := m.Called(ctx, name, opts) + return args.Get(0).(*clusterv1beta2.ManagedClusterSet), args.Error(1) +} + +func (m *MockPlacementsInterface) List(ctx context.Context, opts metav1.ListOptions) (*clusterv1beta1.PlacementList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*clusterv1beta1.PlacementList), args.Error(1) +} + +func (m *MockPlacementsInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*clusterv1beta1.Placement, error) { + args := m.Called(ctx, name, opts) + return args.Get(0).(*clusterv1beta1.Placement), args.Error(1) +} + +func (m *MockPlacementDecisionsInterface) List(ctx context.Context, opts metav1.ListOptions) (*clusterv1beta1.PlacementDecisionList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*clusterv1beta1.PlacementDecisionList), args.Error(1) +} + +func (m *MockAddonV1Alpha1Interface) ManagedClusterAddOns(namespace string) *MockManagedClusterAddOnsInterface { + args := m.Called(namespace) + return args.Get(0).(*MockManagedClusterAddOnsInterface) +} + +func (m *MockManagedClusterAddOnsInterface) List(ctx context.Context, opts metav1.ListOptions) (*addonv1alpha1.ManagedClusterAddOnList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*addonv1alpha1.ManagedClusterAddOnList), args.Error(1) +} + +func (m *MockManagedClusterAddOnsInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*addonv1alpha1.ManagedClusterAddOn, error) { + args := m.Called(ctx, name, opts) + return args.Get(0).(*addonv1alpha1.ManagedClusterAddOn), args.Error(1) +} + +func (m *MockWorkV1Interface) ManifestWorks(namespace string) *MockManifestWorksInterface { + args := m.Called(namespace) + return args.Get(0).(*MockManifestWorksInterface) +} + +func (m *MockManifestWorksInterface) List(ctx context.Context, opts metav1.ListOptions) (*workv1.ManifestWorkList, error) { + args := m.Called(ctx, opts) + return args.Get(0).(*workv1.ManifestWorkList), args.Error(1) +} + +func (m *MockManifestWorksInterface) Get(ctx context.Context, name string, opts metav1.GetOptions) (*workv1.ManifestWork, error) { + args := m.Called(ctx, name, opts) + return args.Get(0).(*workv1.ManifestWork), args.Error(1) +} diff --git a/dashboard/apiserver/pkg/client/ocm.go b/dashboard/apiserver/pkg/client/ocm.go new file mode 100644 index 00000000..8042ce85 --- /dev/null +++ b/dashboard/apiserver/pkg/client/ocm.go @@ -0,0 +1,85 @@ +package client + +import ( + "log" + + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + addonv1alpha1client "open-cluster-management.io/api/client/addon/clientset/versioned" + addonv1alpha1informers "open-cluster-management.io/api/client/addon/informers/externalversions" + clusterv1client "open-cluster-management.io/api/client/cluster/clientset/versioned" + clusterv1informers "open-cluster-management.io/api/client/cluster/informers/externalversions" + workv1client "open-cluster-management.io/api/client/work/clientset/versioned" + workv1informers "open-cluster-management.io/api/client/work/informers/externalversions" +) + +// OCMClient holds clients for OCM resources +type OCMClient struct { + // Dynamic client for backward compatibility + dynamic.Interface + + // Standard Kubernetes client for authentication operations + KubernetesClient kubernetes.Interface + + // OCM typed clients + ClusterClient clusterv1client.Interface + AddonClient addonv1alpha1client.Interface + WorkClient workv1client.Interface + + // OCM informers + ClusterInformerFactory clusterv1informers.SharedInformerFactory + AddonInformerFactory addonv1alpha1informers.SharedInformerFactory + WorkInformerFactory workv1informers.SharedInformerFactory +} + +// CreateOCMClient initializes OCM clients using the provided config +func CreateOCMClient(config *rest.Config) (*OCMClient, error) { + // Create dynamic client (for backward compatibility) + dynamicClient, err := dynamic.NewForConfig(config) + if err != nil { + return nil, err + } + + // Create standard Kubernetes client + kubernetesClient, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + // Create cluster client + clusterClient, err := clusterv1client.NewForConfig(config) + if err != nil { + return nil, err + } + + // Create addon client + addonClient, err := addonv1alpha1client.NewForConfig(config) + if err != nil { + return nil, err + } + + // Create work client + workClient, err := workv1client.NewForConfig(config) + if err != nil { + return nil, err + } + + // Create informer factories + clusterInformerFactory := clusterv1informers.NewSharedInformerFactory(clusterClient, 0) + addonInformerFactory := addonv1alpha1informers.NewSharedInformerFactory(addonClient, 0) + workInformerFactory := workv1informers.NewSharedInformerFactory(workClient, 0) + + log.Println("Successfully created OCM clients") + + return &OCMClient{ + Interface: dynamicClient, + KubernetesClient: kubernetesClient, + ClusterClient: clusterClient, + AddonClient: addonClient, + WorkClient: workClient, + ClusterInformerFactory: clusterInformerFactory, + AddonInformerFactory: addonInformerFactory, + WorkInformerFactory: workInformerFactory, + }, nil +} diff --git a/dashboard/apiserver/pkg/client/resources.go b/dashboard/apiserver/pkg/client/resources.go new file mode 100644 index 00000000..9457c546 --- /dev/null +++ b/dashboard/apiserver/pkg/client/resources.go @@ -0,0 +1,39 @@ +package client + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Resources to work with - ManagedCluster and ManagedClusterSet from OCM +var ManagedClusterResource = schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1", + Resource: "managedclusters", +} + +var ManagedClusterSetResource = schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1beta2", + Resource: "managedclustersets", +} + +// ManagedClusterAddon resource +var ManagedClusterAddonResource = schema.GroupVersionResource{ + Group: "addon.open-cluster-management.io", + Version: "v1alpha1", + Resource: "managedclusteraddons", +} + +// Placement resource +var PlacementResource = schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1beta1", + Resource: "placements", +} + +// PlacementDecision resource +var PlacementDecisionResource = schema.GroupVersionResource{ + Group: "cluster.open-cluster-management.io", + Version: "v1beta1", + Resource: "placementdecisions", +} diff --git a/dashboard/apiserver/pkg/handlers/addons.go b/dashboard/apiserver/pkg/handlers/addons.go new file mode 100644 index 00000000..fe5c9445 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/addons.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// GetClusterAddons handles retrieving all addons for a specific cluster +func GetClusterAddons(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + clusterName := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.AddonClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // List real managed cluster addons for the specific namespace (cluster name) + list, err := ocmClient.AddonClient.AddonV1alpha1().ManagedClusterAddOns(clusterName).List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ManagedClusterAddon format + addons := make([]models.ManagedClusterAddon, 0, len(list.Items)) + for _, item := range list.Items { + // Extract the basic metadata + addon := models.ManagedClusterAddon{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Extract installNamespace from spec + if item.Spec.InstallNamespace != "" { + addon.InstallNamespace = item.Spec.InstallNamespace + } + + // Extract conditions from status + for _, condition := range item.Status.Conditions { + addon.Conditions = append(addon.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + Reason: condition.Reason, + Message: condition.Message, + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + }) + } + + // Extract registrations from status + for _, registration := range item.Status.Registrations { + reg := models.AddonRegistration{ + SignerName: registration.SignerName, + } + + if registration.Subject.User != "" { + reg.Subject.User = registration.Subject.User + } + + if len(registration.Subject.Groups) > 0 { + reg.Subject.Groups = registration.Subject.Groups + } + + addon.Registrations = append(addon.Registrations, reg) + } + + // Extract supportedConfigs from status + for _, config := range item.Status.SupportedConfigs { + addon.SupportedConfigs = append(addon.SupportedConfigs, models.AddonSupportedConfig{ + Group: config.Group, + Resource: config.Resource, + }) + } + + addons = append(addons, addon) + } + + c.JSON(http.StatusOK, addons) +} + +// GetClusterAddon handles retrieving a specific addon for a specific cluster +func GetClusterAddon(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + clusterName := c.Param("name") + addonName := c.Param("addonName") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.AddonClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Get the real managed cluster addon + item, err := ocmClient.AddonClient.AddonV1alpha1().ManagedClusterAddOns(clusterName).Get(ctx, addonName, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("Addon %s not found for cluster %s", addonName, clusterName)}) + return + } + + // Extract the basic metadata + addon := models.ManagedClusterAddon{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Extract installNamespace from spec + if item.Spec.InstallNamespace != "" { + addon.InstallNamespace = item.Spec.InstallNamespace + } + + // Extract conditions from status + for _, condition := range item.Status.Conditions { + addon.Conditions = append(addon.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + Reason: condition.Reason, + Message: condition.Message, + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + }) + } + + // Extract registrations from status + for _, registration := range item.Status.Registrations { + reg := models.AddonRegistration{ + SignerName: registration.SignerName, + } + + if registration.Subject.User != "" { + reg.Subject.User = registration.Subject.User + } + + if len(registration.Subject.Groups) > 0 { + reg.Subject.Groups = registration.Subject.Groups + } + + addon.Registrations = append(addon.Registrations, reg) + } + + // Extract supportedConfigs from status + for _, config := range item.Status.SupportedConfigs { + addon.SupportedConfigs = append(addon.SupportedConfigs, models.AddonSupportedConfig{ + Group: config.Group, + Resource: config.Resource, + }) + } + + c.JSON(http.StatusOK, addon) +} diff --git a/dashboard/apiserver/pkg/handlers/addons_test.go b/dashboard/apiserver/pkg/handlers/addons_test.go new file mode 100644 index 00000000..c1e0eb30 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/addons_test.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetClusterAddons(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + clusterName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + clusterName: "test-cluster", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil addon client", + clusterName: "test-cluster", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: tt.clusterName}} + + ctx := context.Background() + + GetClusterAddons(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetClusterAddon(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + clusterName string + addonName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + clusterName: "test-cluster", + addonName: "test-addon", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil addon client", + clusterName: "test-cluster", + addonName: "test-addon", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "name", Value: tt.clusterName}, + {Key: "addonName", Value: tt.addonName}, + } + + ctx := context.Background() + + GetClusterAddon(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/clusters.go b/dashboard/apiserver/pkg/handlers/clusters.go new file mode 100644 index 00000000..fc682b45 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clusters.go @@ -0,0 +1,144 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "open-cluster-management.io/api/cluster/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// GetClusters handles retrieving all clusters +func GetClusters(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Use the OCM typed client to list ManagedClusters + clusterList, err := ocmClient.ClusterClient.ClusterV1().ManagedClusters().List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified Cluster format + clusters := make([]models.Cluster, 0, len(clusterList.Items)) + for _, item := range clusterList.Items { + // Create a cluster object from the ManagedCluster + cluster := convertManagedClusterToCluster(item) + clusters = append(clusters, cluster) + } + + c.JSON(http.StatusOK, clusters) +} + +// GetCluster handles retrieving a specific cluster by name +func GetCluster(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Use the OCM typed client to get a specific ManagedCluster + managedCluster, err := ocmClient.ClusterClient.ClusterV1().ManagedClusters().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified Cluster format + cluster := convertManagedClusterToCluster(*managedCluster) + + c.JSON(http.StatusOK, cluster) +} + +// Helper function to convert a ManagedCluster to our simplified Cluster model +func convertManagedClusterToCluster(managedCluster clusterv1.ManagedCluster) models.Cluster { + cluster := models.Cluster{ + ID: string(managedCluster.ObjectMeta.UID), + Name: managedCluster.ObjectMeta.Name, + Labels: managedCluster.ObjectMeta.Labels, + CreationTimestamp: managedCluster.ObjectMeta.CreationTimestamp.Format(time.RFC3339), + Status: "Unknown", + } + + // Extract Kubernetes version + if managedCluster.Status.Version.Kubernetes != "" { + cluster.Version = managedCluster.Status.Version.Kubernetes + } + + // Extract capacity and allocatable resources + if len(managedCluster.Status.Capacity) > 0 { + resourceMap := make(map[string]string) + for k, v := range managedCluster.Status.Capacity { + resourceMap[string(k)] = v.String() + } + cluster.Capacity = resourceMap + } + + if len(managedCluster.Status.Allocatable) > 0 { + resourceMap := make(map[string]string) + for k, v := range managedCluster.Status.Allocatable { + resourceMap[string(k)] = v.String() + } + cluster.Allocatable = resourceMap + } + + // Convert cluster claims + if len(managedCluster.Status.ClusterClaims) > 0 { + claims := make([]models.ClusterClaim, 0, len(managedCluster.Status.ClusterClaims)) + for _, c := range managedCluster.Status.ClusterClaims { + claims = append(claims, models.ClusterClaim{ + Name: c.Name, + Value: c.Value, + }) + } + cluster.ClusterClaims = claims + } + + // Convert conditions + if len(managedCluster.Status.Conditions) > 0 { + conditions := make([]models.Condition, 0, len(managedCluster.Status.Conditions)) + for _, c := range managedCluster.Status.Conditions { + conditions = append(conditions, models.Condition{ + Type: string(c.Type), + Status: string(c.Status), + LastTransitionTime: c.LastTransitionTime.Format(time.RFC3339), + Reason: c.Reason, + Message: c.Message, + }) + + // Update status based on ManagedClusterConditionAvailable condition + if c.Type == clusterv1.ManagedClusterConditionAvailable && c.Status == metav1.ConditionTrue { + cluster.Status = "Online" + } else if c.Type == clusterv1.ManagedClusterConditionAvailable && c.Status != metav1.ConditionTrue { + cluster.Status = "Offline" + } + } + cluster.Conditions = conditions + } + + // Add cluster client configs + if len(managedCluster.Spec.ManagedClusterClientConfigs) > 0 { + configs := make([]models.ManagedClusterClientConfig, 0, len(managedCluster.Spec.ManagedClusterClientConfigs)) + for _, cc := range managedCluster.Spec.ManagedClusterClientConfigs { + configs = append(configs, models.ManagedClusterClientConfig{ + URL: cc.URL, + CABundle: string(cc.CABundle), + }) + } + cluster.ManagedClusterClientConfigs = configs + } + + return cluster +} diff --git a/dashboard/apiserver/pkg/handlers/clusters_test.go b/dashboard/apiserver/pkg/handlers/clusters_test.go new file mode 100644 index 00000000..49ed34ca --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clusters_test.go @@ -0,0 +1,224 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + clusterv1 "open-cluster-management.io/api/cluster/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetClusters(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + ctx := context.Background() + + GetClusters(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.expectedStatus == http.StatusInternalServerError { + assert.Contains(t, w.Body.String(), "error") + } + }) + } +} + +func TestGetCluster(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + clusterName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + clusterName: "test-cluster", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: tt.clusterName}} + + ctx := context.Background() + + GetCluster(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + + if tt.expectedStatus == http.StatusInternalServerError { + assert.Contains(t, w.Body.String(), "error") + } + }) + } +} + +func TestConvertManagedClusterToCluster(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + managedCluster clusterv1.ManagedCluster + expectedStatus string + expectedVersion string + }{ + { + name: "cluster with available condition", + managedCluster: clusterv1.ManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + UID: types.UID("test-uid"), + Labels: map[string]string{ + "env": "test", + }, + CreationTimestamp: metav1.Time{Time: now}, + }, + Status: clusterv1.ManagedClusterStatus{ + Version: clusterv1.ManagedClusterVersion{ + Kubernetes: "v1.20.0", + }, + Capacity: clusterv1.ResourceList{ + "cpu": resource.MustParse("4"), + "memory": resource.MustParse("8Gi"), + }, + Allocatable: clusterv1.ResourceList{ + "cpu": resource.MustParse("3.5"), + "memory": resource.MustParse("7Gi"), + }, + Conditions: []metav1.Condition{ + { + Type: string(clusterv1.ManagedClusterConditionAvailable), + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Time{Time: now}, + Reason: "Available", + Message: "Cluster is available", + }, + }, + ClusterClaims: []clusterv1.ManagedClusterClaim{ + { + Name: "platform.open-cluster-management.io", + Value: "AWS", + }, + }, + }, + Spec: clusterv1.ManagedClusterSpec{ + ManagedClusterClientConfigs: []clusterv1.ClientConfig{ + { + URL: "https://test-cluster:6443", + CABundle: []byte("test-ca-bundle"), + }, + }, + }, + }, + expectedStatus: "Online", + expectedVersion: "v1.20.0", + }, + { + name: "cluster with unavailable condition", + managedCluster: clusterv1.ManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-2", + UID: types.UID("test-uid-2"), + CreationTimestamp: metav1.Time{Time: now}, + }, + Status: clusterv1.ManagedClusterStatus{ + Conditions: []metav1.Condition{ + { + Type: string(clusterv1.ManagedClusterConditionAvailable), + Status: metav1.ConditionFalse, + LastTransitionTime: metav1.Time{Time: now}, + Reason: "Unavailable", + Message: "Cluster is not available", + }, + }, + }, + }, + expectedStatus: "Offline", + expectedVersion: "", + }, + { + name: "cluster without conditions", + managedCluster: clusterv1.ManagedCluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster-3", + UID: types.UID("test-uid-3"), + CreationTimestamp: metav1.Time{Time: now}, + }, + }, + expectedStatus: "Unknown", + expectedVersion: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cluster := convertManagedClusterToCluster(tt.managedCluster) + + assert.Equal(t, string(tt.managedCluster.UID), cluster.ID) + assert.Equal(t, tt.managedCluster.Name, cluster.Name) + assert.Equal(t, tt.expectedStatus, cluster.Status) + assert.Equal(t, tt.expectedVersion, cluster.Version) + assert.Equal(t, tt.managedCluster.Labels, cluster.Labels) + assert.Equal(t, tt.managedCluster.CreationTimestamp.Format(time.RFC3339), cluster.CreationTimestamp) + + if len(tt.managedCluster.Status.Capacity) > 0 { + assert.NotEmpty(t, cluster.Capacity) + assert.Equal(t, "4", cluster.Capacity["cpu"]) + } + + if len(tt.managedCluster.Status.Allocatable) > 0 { + assert.NotEmpty(t, cluster.Allocatable) + assert.Equal(t, "3500m", cluster.Allocatable["cpu"]) + } + + if len(tt.managedCluster.Status.ClusterClaims) > 0 { + assert.Len(t, cluster.ClusterClaims, len(tt.managedCluster.Status.ClusterClaims)) + assert.Equal(t, "platform.open-cluster-management.io", cluster.ClusterClaims[0].Name) + assert.Equal(t, "AWS", cluster.ClusterClaims[0].Value) + } + + if len(tt.managedCluster.Status.Conditions) > 0 { + assert.Len(t, cluster.Conditions, len(tt.managedCluster.Status.Conditions)) + } + + if len(tt.managedCluster.Spec.ManagedClusterClientConfigs) > 0 { + assert.Len(t, cluster.ManagedClusterClientConfigs, len(tt.managedCluster.Spec.ManagedClusterClientConfigs)) + assert.Equal(t, "https://test-cluster:6443", cluster.ManagedClusterClientConfigs[0].URL) + assert.Equal(t, "test-ca-bundle", cluster.ManagedClusterClientConfigs[0].CABundle) + } + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/clustersetbindings.go b/dashboard/apiserver/pkg/handlers/clustersetbindings.go new file mode 100644 index 00000000..e94b5b2c --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clustersetbindings.go @@ -0,0 +1,151 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// GetAllClusterSetBindings retrieves all ManagedClusterSetBindings across all namespaces +func GetAllClusterSetBindings(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Since we don't have a direct way to list resources across all namespaces with the OCM client, + // we'll use an empty string to indicate "all namespaces" if the API supports it + list, err := ocmClient.ClusterClient.ClusterV1beta2().ManagedClusterSetBindings("").List(ctx, metav1.ListOptions{}) + if err != nil { + // If listing across all namespaces is not supported, we'll handle the error + c.JSON(http.StatusInternalServerError, gin.H{"error": "Unable to list clustersetbindings across all namespaces: " + err.Error()}) + return + } + + // Convert to our simplified ClusterSetBinding models + allBindings := make([]models.ManagedClusterSetBinding, 0, len(list.Items)) + + for _, item := range list.Items { + binding := models.ManagedClusterSetBinding{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + Spec: models.ManagedClusterSetBindingSpec{ + ClusterSet: item.Spec.ClusterSet, + }, + } + + // Extract status info (conditions) + for _, condition := range item.Status.Conditions { + binding.Status.Conditions = append(binding.Status.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + allBindings = append(allBindings, binding) + } + + c.JSON(http.StatusOK, allBindings) +} + +// GetClusterSetBindings retrieves all ManagedClusterSetBindings for a specific namespace +func GetClusterSetBindings(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Get the cluster set bindings for the specified namespace + list, err := ocmClient.ClusterClient.ClusterV1beta2().ManagedClusterSetBindings(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ClusterSetBinding models + clusterSetBindings := make([]models.ManagedClusterSetBinding, 0, len(list.Items)) + for _, item := range list.Items { + clusterSetBinding := models.ManagedClusterSetBinding{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + Spec: models.ManagedClusterSetBindingSpec{ + ClusterSet: item.Spec.ClusterSet, + }, + } + + // Extract status info (conditions) + for _, condition := range item.Status.Conditions { + clusterSetBinding.Status.Conditions = append(clusterSetBinding.Status.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + clusterSetBindings = append(clusterSetBindings, clusterSetBinding) + } + + c.JSON(http.StatusOK, clusterSetBindings) +} + +// GetClusterSetBinding retrieves a specific ManagedClusterSetBinding by name in a namespace +func GetClusterSetBinding(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Get the cluster set binding by name + item, err := ocmClient.ClusterClient.ClusterV1beta2().ManagedClusterSetBindings(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ClusterSetBinding model + clusterSetBinding := models.ManagedClusterSetBinding{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + Spec: models.ManagedClusterSetBindingSpec{ + ClusterSet: item.Spec.ClusterSet, + }, + } + + // Extract status info (conditions) + for _, condition := range item.Status.Conditions { + clusterSetBinding.Status.Conditions = append(clusterSetBinding.Status.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + c.JSON(http.StatusOK, clusterSetBinding) +} diff --git a/dashboard/apiserver/pkg/handlers/clustersetbindings_test.go b/dashboard/apiserver/pkg/handlers/clustersetbindings_test.go new file mode 100644 index 00000000..a2f01d1b --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clustersetbindings_test.go @@ -0,0 +1,111 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetAllClusterSetBindings(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + ctx := context.Background() + + GetAllClusterSetBindings(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetClusterSetBindings(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "namespace", Value: tt.namespace}} + + ctx := context.Background() + + GetClusterSetBindings(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetClusterSetBinding(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + bindingName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + bindingName: "test-binding", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "namespace", Value: tt.namespace}, + {Key: "name", Value: tt.bindingName}, + } + + ctx := context.Background() + + GetClusterSetBinding(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/clustersets.go b/dashboard/apiserver/pkg/handlers/clustersets.go new file mode 100644 index 00000000..14c0db71 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clustersets.go @@ -0,0 +1,113 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// GetClusterSets handles retrieving all cluster sets +func GetClusterSets(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Normal processing - list real managed cluster sets using OCM client + list, err := ocmClient.ClusterClient.ClusterV1beta2().ManagedClusterSets().List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ClusterSet format + clusterSets := make([]models.ClusterSet, 0, len(list.Items)) + for _, item := range list.Items { + // Extract the basic metadata + clusterSet := models.ClusterSet{ + ID: string(item.GetUID()), + Name: item.GetName(), + Labels: item.GetLabels(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Extract spec info + clusterSet.Spec.ClusterSelector.SelectorType = string(item.Spec.ClusterSelector.SelectorType) + + if item.Spec.ClusterSelector.LabelSelector != nil { + clusterSet.Spec.ClusterSelector.LabelSelector = &models.LabelSelector{ + MatchLabels: item.Spec.ClusterSelector.LabelSelector.MatchLabels, + } + } + + // Extract status info + for _, condition := range item.Status.Conditions { + clusterSet.Status.Conditions = append(clusterSet.Status.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + clusterSets = append(clusterSets, clusterSet) + } + + c.JSON(http.StatusOK, clusterSets) +} + +// GetClusterSet handles retrieving a specific cluster set +func GetClusterSet(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Get the cluster set by name using OCM client + item, err := ocmClient.ClusterClient.ClusterV1beta2().ManagedClusterSets().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ClusterSet format + clusterSet := models.ClusterSet{ + ID: string(item.GetUID()), + Name: item.GetName(), + Labels: item.GetLabels(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Extract spec info + clusterSet.Spec.ClusterSelector.SelectorType = string(item.Spec.ClusterSelector.SelectorType) + + if item.Spec.ClusterSelector.LabelSelector != nil { + clusterSet.Spec.ClusterSelector.LabelSelector = &models.LabelSelector{ + MatchLabels: item.Spec.ClusterSelector.LabelSelector.MatchLabels, + } + } + + // Extract status info + for _, condition := range item.Status.Conditions { + clusterSet.Status.Conditions = append(clusterSet.Status.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + c.JSON(http.StatusOK, clusterSet) +} diff --git a/dashboard/apiserver/pkg/handlers/clustersets_test.go b/dashboard/apiserver/pkg/handlers/clustersets_test.go new file mode 100644 index 00000000..f470af38 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/clustersets_test.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetClusterSets(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + ctx := context.Background() + + GetClusterSets(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetClusterSet(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + clusterSetName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + clusterSetName: "test-clusterset", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "name", Value: tt.clusterSetName}} + + ctx := context.Background() + + GetClusterSet(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/manifestwork.go b/dashboard/apiserver/pkg/handlers/manifestwork.go new file mode 100644 index 00000000..4bd9775c --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/manifestwork.go @@ -0,0 +1,190 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// GetManifestWorks retrieves all ManifestWorks for a specific namespace +func GetManifestWorks(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Get the manifest works for the specified namespace + list, err := ocmClient.WorkClient.WorkV1().ManifestWorks(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ManifestWork models + manifestWorks := make([]models.ManifestWork, 0, len(list.Items)) + for _, item := range list.Items { + manifestWork := models.ManifestWork{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + Labels: item.GetLabels(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Process manifests + if len(item.Spec.Workload.Manifests) > 0 { + manifestWork.Manifests = make([]models.Manifest, len(item.Spec.Workload.Manifests)) + for i, manifest := range item.Spec.Workload.Manifests { + // Convert raw bytes to map[string]interface{} + var rawObj map[string]interface{} + if err := json.Unmarshal(manifest.Raw, &rawObj); err == nil { + manifestWork.Manifests[i] = models.Manifest{ + RawExtension: rawObj, + } + } + } + } + + // Extract conditions + for _, condition := range item.Status.Conditions { + manifestWork.Conditions = append(manifestWork.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + // Process resource status + if len(item.Status.ResourceStatus.Manifests) > 0 { + manifestWork.ResourceStatus.Manifests = make([]models.ManifestCondition, len(item.Status.ResourceStatus.Manifests)) + for i, manifestStatus := range item.Status.ResourceStatus.Manifests { + manifestCondition := models.ManifestCondition{ + ResourceMeta: models.ManifestResourceMeta{ + Ordinal: manifestStatus.ResourceMeta.Ordinal, + Group: manifestStatus.ResourceMeta.Group, + Version: manifestStatus.ResourceMeta.Version, + Kind: manifestStatus.ResourceMeta.Kind, + Resource: manifestStatus.ResourceMeta.Resource, + Name: manifestStatus.ResourceMeta.Name, + Namespace: manifestStatus.ResourceMeta.Namespace, + }, + } + + // Process conditions for this manifest + for _, condition := range manifestStatus.Conditions { + manifestCondition.Conditions = append(manifestCondition.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + manifestWork.ResourceStatus.Manifests[i] = manifestCondition + } + } + + manifestWorks = append(manifestWorks, manifestWork) + } + + c.JSON(http.StatusOK, manifestWorks) +} + +// GetManifestWork retrieves a specific ManifestWork by name in a namespace +func GetManifestWork(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Get the manifest work by name + item, err := ocmClient.WorkClient.WorkV1().ManifestWorks(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified ManifestWork model + manifestWork := models.ManifestWork{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + Labels: item.GetLabels(), + CreationTimestamp: item.GetCreationTimestamp().Format(time.RFC3339), + } + + // Process manifests + if len(item.Spec.Workload.Manifests) > 0 { + manifestWork.Manifests = make([]models.Manifest, len(item.Spec.Workload.Manifests)) + for i, manifest := range item.Spec.Workload.Manifests { + // Convert raw bytes to map[string]interface{} + var rawObj map[string]interface{} + if err := json.Unmarshal(manifest.Raw, &rawObj); err == nil { + manifestWork.Manifests[i] = models.Manifest{ + RawExtension: rawObj, + } + } + } + } + + // Extract conditions + for _, condition := range item.Status.Conditions { + manifestWork.Conditions = append(manifestWork.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + // Process resource status + if len(item.Status.ResourceStatus.Manifests) > 0 { + manifestWork.ResourceStatus.Manifests = make([]models.ManifestCondition, len(item.Status.ResourceStatus.Manifests)) + for i, manifestStatus := range item.Status.ResourceStatus.Manifests { + manifestCondition := models.ManifestCondition{ + ResourceMeta: models.ManifestResourceMeta{ + Ordinal: manifestStatus.ResourceMeta.Ordinal, + Group: manifestStatus.ResourceMeta.Group, + Version: manifestStatus.ResourceMeta.Version, + Kind: manifestStatus.ResourceMeta.Kind, + Resource: manifestStatus.ResourceMeta.Resource, + Name: manifestStatus.ResourceMeta.Name, + Namespace: manifestStatus.ResourceMeta.Namespace, + }, + } + + // Process conditions for this manifest + for _, condition := range manifestStatus.Conditions { + manifestCondition.Conditions = append(manifestCondition.Conditions, models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + }) + } + + manifestWork.ResourceStatus.Manifests[i] = manifestCondition + } + } + + c.JSON(http.StatusOK, manifestWork) +} diff --git a/dashboard/apiserver/pkg/handlers/manifestwork_test.go b/dashboard/apiserver/pkg/handlers/manifestwork_test.go new file mode 100644 index 00000000..4e97c679 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/manifestwork_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetManifestWorks(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "namespace", Value: tt.namespace}} + + ctx := context.Background() + + GetManifestWorks(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetManifestWork(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + manifestName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + manifestName: "test-manifest", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "namespace", Value: tt.namespace}, + {Key: "name", Value: tt.manifestName}, + } + + ctx := context.Background() + + GetManifestWork(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/placementdecisions.go b/dashboard/apiserver/pkg/handlers/placementdecisions.go new file mode 100644 index 00000000..f8364973 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/placementdecisions.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" + + clusterv1beta1 "open-cluster-management.io/api/cluster/v1beta1" +) + +// GetAllPlacementDecisions handles retrieving all placement decisions across namespaces +func GetAllPlacementDecisions(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Use the OCM cluster client to list placement decisions + pdList, err := ocmClient.ClusterClient.ClusterV1beta1().PlacementDecisions("").List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified PlacementDecision format + placementDecisions := make([]models.PlacementDecision, 0, len(pdList.Items)) + for _, pd := range pdList.Items { + placementDecision := convertPlacementDecisionToModel(&pd) + placementDecisions = append(placementDecisions, placementDecision) + } + + c.JSON(http.StatusOK, placementDecisions) +} + +// GetPlacementDecisionsByNamespace handles retrieving placement decisions in a specific namespace +func GetPlacementDecisionsByNamespace(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Use the OCM cluster client to list placement decisions in the namespace + pdList, err := ocmClient.ClusterClient.ClusterV1beta1().PlacementDecisions(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified PlacementDecision format + placementDecisions := make([]models.PlacementDecision, 0, len(pdList.Items)) + for _, pd := range pdList.Items { + placementDecision := convertPlacementDecisionToModel(&pd) + placementDecisions = append(placementDecisions, placementDecision) + } + + c.JSON(http.StatusOK, placementDecisions) +} + +// GetPlacementDecision handles retrieving a specific placement decision +func GetPlacementDecision(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Get the specific placement decision using the OCM cluster client + pd, err := ocmClient.ClusterClient.ClusterV1beta1().PlacementDecisions(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified PlacementDecision format + placementDecision := convertPlacementDecisionToModel(pd) + + c.JSON(http.StatusOK, placementDecision) +} + +// GetPlacementDecisionsByPlacement handles retrieving placement decisions for a specific placement +func GetPlacementDecisionsByPlacement(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // List placement decisions for this placement + // We need to use a label selector to find decisions related to this placement + listOptions := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("cluster.open-cluster-management.io/placement=%s", name), + } + + pdList, err := ocmClient.ClusterClient.ClusterV1beta1().PlacementDecisions(namespace).List(ctx, listOptions) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified PlacementDecision format + placementDecisions := make([]models.PlacementDecision, 0, len(pdList.Items)) + for _, pd := range pdList.Items { + placementDecision := convertPlacementDecisionToModel(&pd) + placementDecisions = append(placementDecisions, placementDecision) + } + + c.JSON(http.StatusOK, placementDecisions) +} + +// Helper function to convert a PlacementDecision resource to our model +func convertPlacementDecisionToModel(pd *clusterv1beta1.PlacementDecision) models.PlacementDecision { + placementDecision := models.PlacementDecision{ + ID: string(pd.GetUID()), + Name: pd.GetName(), + Namespace: pd.GetNamespace(), + } + + // Extract decisions from the PlacementDecision status + decisions := make([]models.ClusterDecision, 0, len(pd.Status.Decisions)) + for _, decision := range pd.Status.Decisions { + clusterDecision := models.ClusterDecision{ + ClusterName: decision.ClusterName, + Reason: decision.Reason, + } + decisions = append(decisions, clusterDecision) + } + + placementDecision.Decisions = decisions + + return placementDecision +} diff --git a/dashboard/apiserver/pkg/handlers/placementdecisions_test.go b/dashboard/apiserver/pkg/handlers/placementdecisions_test.go new file mode 100644 index 00000000..f60b8958 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/placementdecisions_test.go @@ -0,0 +1,82 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "open-cluster-management-io/lab/apiserver/pkg/client" +) + +func TestGetPlacementDecisionsByNamespace(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "namespace", Value: tt.namespace}} + + ctx := context.Background() + + GetPlacementDecisionsByNamespace(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetPlacementDecision(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + decisionName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + decisionName: "test-decision", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "namespace", Value: tt.namespace}, + {Key: "name", Value: tt.decisionName}, + } + + ctx := context.Background() + + GetPlacementDecision(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/placements.go b/dashboard/apiserver/pkg/handlers/placements.go new file mode 100644 index 00000000..8343ec34 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/placements.go @@ -0,0 +1,251 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" + + clusterv1beta1 "open-cluster-management.io/api/cluster/v1beta1" +) + +// GetPlacements handles retrieving all placements across namespaces +func GetPlacements(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Use the OCM cluster client to list placements + placementList, err := ocmClient.ClusterClient.ClusterV1beta1().Placements("").List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified Placement format + placements := make([]models.Placement, 0, len(placementList.Items)) + for _, placement := range placementList.Items { + placementModel := convertPlacementToModel(placement) + placements = append(placements, placementModel) + } + + c.JSON(http.StatusOK, placements) +} + +// GetPlacementsByNamespace handles retrieving placements for a specific namespace +func GetPlacementsByNamespace(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Use the OCM cluster client to list placements in the specified namespace + placementList, err := ocmClient.ClusterClient.ClusterV1beta1().Placements(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified Placement format + placements := make([]models.Placement, 0, len(placementList.Items)) + for _, placement := range placementList.Items { + placementModel := convertPlacementToModel(placement) + placements = append(placements, placementModel) + } + + c.JSON(http.StatusOK, placements) +} + +// GetPlacement handles retrieving a specific placement +func GetPlacement(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + name := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // Get the specific placement using the OCM cluster client + placement, err := ocmClient.ClusterClient.ClusterV1beta1().Placements(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified Placement format + placementModel := convertPlacementToModel(*placement) + + c.JSON(http.StatusOK, placementModel) +} + +// GetPlacementDecisions handles retrieving decisions for a placement +func GetPlacementDecisions(c *gin.Context, ocmClient *client.OCMClient, ctx context.Context) { + namespace := c.Param("namespace") + placementName := c.Param("name") + + // Ensure we have a client before proceeding + if ocmClient == nil || ocmClient.ClusterClient == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "OCM client not initialized"}) + return + } + + // List placement decisions for this placement + // We need to use a label selector to find decisions related to this placement + listOptions := metav1.ListOptions{ + LabelSelector: fmt.Sprintf("cluster.open-cluster-management.io/placement=%s", placementName), + } + + list, err := ocmClient.ClusterClient.ClusterV1beta1().PlacementDecisions(namespace).List(ctx, listOptions) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Convert to our simplified PlacementDecision format + placementDecisions := make([]models.PlacementDecision, 0, len(list.Items)) + for _, item := range list.Items { + placementDecsion := models.PlacementDecision{ + ID: string(item.GetUID()), + Name: item.GetName(), + Namespace: item.GetNamespace(), + } + + decisions := make([]models.ClusterDecision, 0, len(item.Status.Decisions)) + + for _, decision := range item.Status.Decisions { + clusterDecision := models.ClusterDecision{ + ClusterName: decision.ClusterName, + Reason: decision.Reason, + } + decisions = append(decisions, clusterDecision) + } + + placementDecsion.Decisions = decisions + + placementDecisions = append(placementDecisions, placementDecsion) + } + + c.JSON(http.StatusOK, placementDecisions) +} + +// Helper function to convert a Placement resource to our model +func convertPlacementToModel(placement clusterv1beta1.Placement) models.Placement { + p := models.Placement{ + ID: string(placement.UID), + Name: placement.Name, + Namespace: placement.Namespace, + CreationTimestamp: placement.CreationTimestamp.Format(time.RFC3339), + } + + // Extract ClusterSets + if len(placement.Spec.ClusterSets) > 0 { + p.ClusterSets = placement.Spec.ClusterSets + } + + // Extract NumberOfClusters + if placement.Spec.NumberOfClusters != nil { + p.NumberOfClusters = models.IntPtr(int32(*placement.Spec.NumberOfClusters)) + } + + // Extract Predicates + if len(placement.Spec.Predicates) > 0 { + for _, predicate := range placement.Spec.Predicates { + modelPredicate := models.Predicate{} + + // Check if the RequiredClusterSelector is set with a LabelSelector + if predicate.RequiredClusterSelector.LabelSelector.MatchLabels != nil || + len(predicate.RequiredClusterSelector.LabelSelector.MatchExpressions) > 0 { + + modelPredicate.RequiredClusterSelector = &models.RequiredClusterSelector{} + modelLabelSelector := &models.LabelSelectorWithExpressions{ + MatchLabels: predicate.RequiredClusterSelector.LabelSelector.MatchLabels, + } + + // Process label selector expressions if they exist + if len(predicate.RequiredClusterSelector.LabelSelector.MatchExpressions) > 0 { + for _, expr := range predicate.RequiredClusterSelector.LabelSelector.MatchExpressions { + modelExpr := models.MatchExpression{ + Key: expr.Key, + Operator: string(expr.Operator), + Values: expr.Values, + } + modelLabelSelector.MatchExpressions = append(modelLabelSelector.MatchExpressions, modelExpr) + } + } + + modelPredicate.RequiredClusterSelector.LabelSelector = modelLabelSelector + } + + // Check if the RequiredClusterSelector is set with a ClaimSelector + if len(predicate.RequiredClusterSelector.ClaimSelector.MatchExpressions) > 0 { + if modelPredicate.RequiredClusterSelector == nil { + modelPredicate.RequiredClusterSelector = &models.RequiredClusterSelector{} + } + + modelClaimSelector := &models.ClaimSelectorWithExpressions{} + + // Process claim selector expressions + for _, expr := range predicate.RequiredClusterSelector.ClaimSelector.MatchExpressions { + modelExpr := models.MatchExpression{ + Key: expr.Key, + Operator: string(expr.Operator), + Values: expr.Values, + } + modelClaimSelector.MatchExpressions = append(modelClaimSelector.MatchExpressions, modelExpr) + } + + modelPredicate.RequiredClusterSelector.ClaimSelector = modelClaimSelector + } + + p.Predicates = append(p.Predicates, modelPredicate) + } + } + + // Extract status + p.NumberOfSelectedClusters = int32(placement.Status.NumberOfSelectedClusters) + + // Check satisfaction + p.Satisfied = false // default to false + for _, condition := range placement.Status.Conditions { + // Add conditions + modelCondition := models.Condition{ + Type: string(condition.Type), + Status: string(condition.Status), + LastTransitionTime: condition.LastTransitionTime.Format(time.RFC3339), + Reason: condition.Reason, + Message: condition.Message, + } + p.Conditions = append(p.Conditions, modelCondition) + + // Check if placement is satisfied + if condition.Type == clusterv1beta1.PlacementConditionSatisfied && condition.Status == metav1.ConditionTrue { + p.Satisfied = true + } + } + + // Extract decision groups + for i, group := range placement.Status.DecisionGroups { + decisionGroup := models.DecisionGroupStatus{ + DecisionGroupIndex: int32(i), + DecisionGroupName: group.DecisionGroupName, + Decisions: group.Decisions, + ClusterCount: int32(group.ClustersCount), + } + p.DecisionGroups = append(p.DecisionGroups, decisionGroup) + } + + return p +} diff --git a/dashboard/apiserver/pkg/handlers/placements_test.go b/dashboard/apiserver/pkg/handlers/placements_test.go new file mode 100644 index 00000000..ec23cbe0 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/placements_test.go @@ -0,0 +1,287 @@ +package handlers + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + clusterv1beta1 "open-cluster-management.io/api/cluster/v1beta1" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +func TestGetPlacements(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil cluster client", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + ctx := context.Background() + + GetPlacements(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetPlacementsByNamespace(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil cluster client", + namespace: "test-namespace", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "namespace", Value: tt.namespace}} + + ctx := context.Background() + + GetPlacementsByNamespace(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetPlacement(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + placementName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + placementName: "test-placement", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil cluster client", + namespace: "test-namespace", + placementName: "test-placement", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "namespace", Value: tt.namespace}, + {Key: "name", Value: tt.placementName}, + } + + ctx := context.Background() + + GetPlacement(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestGetPlacementDecisions(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + namespace string + placementName string + client *client.OCMClient + expectedStatus int + }{ + { + name: "nil client", + namespace: "test-namespace", + placementName: "test-placement", + client: nil, + expectedStatus: http.StatusInternalServerError, + }, + { + name: "client with nil cluster client", + namespace: "test-namespace", + placementName: "test-placement", + client: &client.OCMClient{}, + expectedStatus: http.StatusInternalServerError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "namespace", Value: tt.namespace}, + {Key: "name", Value: tt.placementName}, + } + + ctx := context.Background() + + GetPlacementDecisions(c, tt.client, ctx) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestConvertPlacementToModel(t *testing.T) { + now := time.Now() + numberOfClusters := int32(3) + + tests := []struct { + name string + placement clusterv1beta1.Placement + expected models.Placement + }{ + { + name: "placement with all fields", + placement: clusterv1beta1.Placement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-placement", + Namespace: "test-namespace", + UID: types.UID("test-uid"), + CreationTimestamp: metav1.Time{Time: now}, + }, + Spec: clusterv1beta1.PlacementSpec{ + ClusterSets: []string{"clusterset1", "clusterset2"}, + NumberOfClusters: &numberOfClusters, + Predicates: []clusterv1beta1.ClusterPredicate{ + { + RequiredClusterSelector: clusterv1beta1.ClusterSelector{ + LabelSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "region", + Operator: metav1.LabelSelectorOpIn, + Values: []string{"us-east-1", "us-west-2"}, + }, + }, + }, + }, + }, + }, + }, + Status: clusterv1beta1.PlacementStatus{ + NumberOfSelectedClusters: 2, + Conditions: []metav1.Condition{ + { + Type: string(clusterv1beta1.PlacementConditionSatisfied), + Status: metav1.ConditionTrue, + LastTransitionTime: metav1.Time{Time: now}, + Reason: "Satisfied", + Message: "Placement is satisfied", + }, + }, + }, + }, + expected: models.Placement{ + ID: "test-uid", + Name: "test-placement", + Namespace: "test-namespace", + CreationTimestamp: now.Format(time.RFC3339), + ClusterSets: []string{"clusterset1", "clusterset2"}, + NumberOfClusters: &numberOfClusters, + NumberOfSelectedClusters: 2, + Satisfied: true, + }, + }, + { + name: "placement with minimal fields", + placement: clusterv1beta1.Placement{ + ObjectMeta: metav1.ObjectMeta{ + Name: "minimal-placement", + Namespace: "default", + UID: types.UID("minimal-uid"), + CreationTimestamp: metav1.Time{Time: now}, + }, + }, + expected: models.Placement{ + ID: "minimal-uid", + Name: "minimal-placement", + Namespace: "default", + CreationTimestamp: now.Format(time.RFC3339), + NumberOfSelectedClusters: 0, + Satisfied: false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertPlacementToModel(tt.placement) + + assert.Equal(t, tt.expected.ID, result.ID) + assert.Equal(t, tt.expected.Name, result.Name) + assert.Equal(t, tt.expected.Namespace, result.Namespace) + assert.Equal(t, tt.expected.CreationTimestamp, result.CreationTimestamp) + assert.Equal(t, tt.expected.ClusterSets, result.ClusterSets) + assert.Equal(t, tt.expected.NumberOfClusters, result.NumberOfClusters) + assert.Equal(t, tt.expected.NumberOfSelectedClusters, result.NumberOfSelectedClusters) + assert.Equal(t, tt.expected.Satisfied, result.Satisfied) + + if len(tt.placement.Spec.Predicates) > 0 { + assert.NotEmpty(t, result.Predicates) + if len(tt.placement.Spec.Predicates[0].RequiredClusterSelector.LabelSelector.MatchLabels) > 0 { + assert.NotNil(t, result.Predicates[0].RequiredClusterSelector) + assert.NotNil(t, result.Predicates[0].RequiredClusterSelector.LabelSelector) + } + } + }) + } +} diff --git a/dashboard/apiserver/pkg/handlers/streaming.go b/dashboard/apiserver/pkg/handlers/streaming.go new file mode 100644 index 00000000..f3c31e4e --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/streaming.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/gin-gonic/gin" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +// StreamClusters handles streaming cluster updates via SSE +func StreamClusters(c *gin.Context, dynamicClient dynamic.Interface, ctx context.Context) { + // Ensure we have a client before proceeding + if dynamicClient == nil { + c.JSON(500, gin.H{"error": "Kubernetes client not initialized"}) + return + } + + // Set headers for SSE + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("Transfer-Encoding", "chunked") + c.Writer.Flush() + + // Create a watch for ManagedClusters + watcher, err := dynamicClient.Resource(client.ManagedClusterResource).Watch(ctx, metav1.ListOptions{}) + if err != nil { + c.Writer.Write([]byte(fmt.Sprintf("event: error\ndata: %s\n\n", err.Error()))) + c.Writer.Flush() + return + } + defer watcher.Stop() + + // Send initial list of clusters + initialList, err := dynamicClient.Resource(client.ManagedClusterResource).List(ctx, metav1.ListOptions{}) + if err != nil { + c.Writer.Write([]byte(fmt.Sprintf("event: error\ndata: %s\n\n", err.Error()))) + c.Writer.Flush() + return + } + + // Convert to our simplified Cluster format + clusters := make([]models.Cluster, 0, len(initialList.Items)) + for _, item := range initialList.Items { + cluster, err := convertToCluster(item) + if err != nil { + continue + } + clusters = append(clusters, cluster) + } + + // Send initial data + data, err := json.Marshal(clusters) + if err != nil { + c.Writer.Write([]byte(fmt.Sprintf("event: error\ndata: %s\n\n", err.Error()))) + c.Writer.Flush() + return + } + c.Writer.Write([]byte(fmt.Sprintf("event: clusters\ndata: %s\n\n", data))) + c.Writer.Flush() + + // Listen for watch events + for { + select { + case <-ctx.Done(): + return + case event, ok := <-watcher.ResultChan(): + if !ok { + // Channel closed, end streaming + return + } + + // Process the event + switch event.Type { + case watch.Added, watch.Modified, watch.Deleted: + // Get updated list to ensure we have full state + updatedList, err := dynamicClient.Resource(client.ManagedClusterResource).List(ctx, metav1.ListOptions{}) + if err != nil { + continue + } + + // Convert again to our simplified format + updatedClusters := make([]models.Cluster, 0, len(updatedList.Items)) + for _, item := range updatedList.Items { + cluster, err := convertToCluster(item) + if err != nil { + continue + } + updatedClusters = append(updatedClusters, cluster) + } + + // Send updated data + data, err := json.Marshal(updatedClusters) + if err != nil { + continue + } + c.Writer.Write([]byte(fmt.Sprintf("event: clusters\ndata: %s\n\n", data))) + c.Writer.Flush() + case watch.Error: + c.Writer.Write([]byte(fmt.Sprintf("event: error\ndata: Watch error occurred\n\n"))) + c.Writer.Flush() + } + case <-time.After(30 * time.Second): + // Send a keepalive ping every 30 seconds + c.Writer.Write([]byte(": ping\n\n")) + c.Writer.Flush() + } + } +} + +// Helper function to convert unstructured item to Cluster +func convertToCluster(item interface{}) (models.Cluster, error) { + // Re-use the logic from GetClusters but without context - keep it simple for streaming + unstructuredMap, err := json.Marshal(item) + if err != nil { + return models.Cluster{}, err + } + + // Convert to our simplified format + var obj map[string]interface{} + if err := json.Unmarshal(unstructuredMap, &obj); err != nil { + return models.Cluster{}, err + } + + // Extract metadata + metadata, ok := obj["metadata"].(map[string]interface{}) + if !ok { + return models.Cluster{}, fmt.Errorf("metadata not found or not a map") + } + + cluster := models.Cluster{ + ID: fmt.Sprintf("%v", metadata["uid"]), + Name: fmt.Sprintf("%v", metadata["name"]), + Status: "Unknown", + CreationTimestamp: fmt.Sprintf("%v", metadata["creationTimestamp"]), + } + + // Extract labels + if labels, ok := metadata["labels"].(map[string]interface{}); ok { + cluster.Labels = make(map[string]string) + for k, v := range labels { + cluster.Labels[k] = fmt.Sprintf("%v", v) + } + } + + // Extract version from status + if status, ok := obj["status"].(map[string]interface{}); ok { + if version, ok := status["version"].(map[string]interface{}); ok { + if k8sVersion, ok := version["kubernetes"].(string); ok { + cluster.Version = k8sVersion + } + } + + // Extract conditions + if conditions, ok := status["conditions"].([]interface{}); ok { + for _, c := range conditions { + condMap, ok := c.(map[string]interface{}) + if !ok { + continue + } + + condition := models.Condition{ + Type: fmt.Sprintf("%v", condMap["type"]), + Status: fmt.Sprintf("%v", condMap["status"]), + Reason: fmt.Sprintf("%v", condMap["reason"]), + Message: fmt.Sprintf("%v", condMap["message"]), + LastTransitionTime: fmt.Sprintf("%v", condMap["lastTransitionTime"]), + } + + cluster.Conditions = append(cluster.Conditions, condition) + + // Update status based on the ManagedClusterConditionAvailable condition + if condition.Type == "ManagedClusterConditionAvailable" && condition.Status == "True" { + cluster.Status = "Online" + } else if condition.Type == "ManagedClusterConditionAvailable" && condition.Status != "True" { + cluster.Status = "Offline" + } + } + } + } + + return cluster, nil +} diff --git a/dashboard/apiserver/pkg/handlers/streaming_test.go b/dashboard/apiserver/pkg/handlers/streaming_test.go new file mode 100644 index 00000000..be8780d8 --- /dev/null +++ b/dashboard/apiserver/pkg/handlers/streaming_test.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "context" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "k8s.io/client-go/dynamic" + + "open-cluster-management-io/lab/apiserver/pkg/models" +) + +func TestStreamClusters(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + dynamicClient dynamic.Interface + expectedStatus int + }{ + { + name: "nil client", + dynamicClient: nil, + expectedStatus: 500, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + ctx := context.Background() + + StreamClusters(c, tt.dynamicClient, ctx) + + if tt.expectedStatus == 500 { + assert.Contains(t, w.Body.String(), "error") + } + }) + } +} + +func TestConvertToCluster(t *testing.T) { + tests := []struct { + name string + item interface{} + expectError bool + expected models.Cluster + }{ + { + name: "valid cluster object", + item: map[string]interface{}{ + "metadata": map[string]interface{}{ + "uid": "test-uid", + "name": "test-cluster", + "creationTimestamp": "2023-01-01T00:00:00Z", + "labels": map[string]interface{}{ + "env": "test", + }, + }, + "status": map[string]interface{}{ + "version": map[string]interface{}{ + "kubernetes": "v1.20.0", + }, + "conditions": []interface{}{ + map[string]interface{}{ + "type": "ManagedClusterConditionAvailable", + "status": "True", + "reason": "Available", + "message": "Cluster is available", + "lastTransitionTime": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + expectError: false, + expected: models.Cluster{ + ID: "test-uid", + Name: "test-cluster", + Status: "Online", + Version: "v1.20.0", + CreationTimestamp: "2023-01-01T00:00:00Z", + Labels: map[string]string{ + "env": "test", + }, + Conditions: []models.Condition{ + { + Type: "ManagedClusterConditionAvailable", + Status: "True", + Reason: "Available", + Message: "Cluster is available", + LastTransitionTime: "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + name: "cluster with offline status", + item: map[string]interface{}{ + "metadata": map[string]interface{}{ + "uid": "test-uid-2", + "name": "test-cluster-2", + "creationTimestamp": "2023-01-01T00:00:00Z", + }, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "ManagedClusterConditionAvailable", + "status": "False", + "reason": "Unavailable", + "message": "Cluster is not available", + "lastTransitionTime": "2023-01-01T00:00:00Z", + }, + }, + }, + }, + expectError: false, + expected: models.Cluster{ + ID: "test-uid-2", + Name: "test-cluster-2", + Status: "Offline", + CreationTimestamp: "2023-01-01T00:00:00Z", + Conditions: []models.Condition{ + { + Type: "ManagedClusterConditionAvailable", + Status: "False", + Reason: "Unavailable", + Message: "Cluster is not available", + LastTransitionTime: "2023-01-01T00:00:00Z", + }, + }, + }, + }, + { + name: "invalid object without metadata", + item: map[string]interface{}{ + "spec": map[string]interface{}{}, + }, + expectError: true, + }, + { + name: "invalid object type", + item: "invalid", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cluster, err := convertToCluster(tt.item) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected.ID, cluster.ID) + assert.Equal(t, tt.expected.Name, cluster.Name) + assert.Equal(t, tt.expected.Status, cluster.Status) + assert.Equal(t, tt.expected.Version, cluster.Version) + assert.Equal(t, tt.expected.CreationTimestamp, cluster.CreationTimestamp) + assert.Equal(t, tt.expected.Labels, cluster.Labels) + + if len(tt.expected.Conditions) > 0 { + assert.Len(t, cluster.Conditions, len(tt.expected.Conditions)) + assert.Equal(t, tt.expected.Conditions[0].Type, cluster.Conditions[0].Type) + assert.Equal(t, tt.expected.Conditions[0].Status, cluster.Conditions[0].Status) + } + } + }) + } +} + +func TestConvertToClusterWithMarshalError(t *testing.T) { + invalidItem := make(chan int) + + _, err := convertToCluster(invalidItem) + assert.Error(t, err) +} + +func TestConvertToClusterWithUnmarshalError(t *testing.T) { + item := map[string]interface{}{ + "metadata": "invalid-metadata-type", + } + + cluster, err := convertToCluster(item) + assert.Error(t, err) + assert.Equal(t, models.Cluster{}, cluster) +} diff --git a/dashboard/apiserver/pkg/models/addon.go b/dashboard/apiserver/pkg/models/addon.go new file mode 100644 index 00000000..e3b1f0c4 --- /dev/null +++ b/dashboard/apiserver/pkg/models/addon.go @@ -0,0 +1,31 @@ +package models + +// AddonRegistrationSubject represents the subject of a registration for an addon +type AddonRegistrationSubject struct { + Groups []string `json:"groups"` + User string `json:"user"` +} + +// AddonRegistration represents the registration information for an addon +type AddonRegistration struct { + SignerName string `json:"signerName"` + Subject AddonRegistrationSubject `json:"subject"` +} + +// AddonSupportedConfig represents a supported configuration for an addon +type AddonSupportedConfig struct { + Group string `json:"group"` + Resource string `json:"resource"` +} + +// ManagedClusterAddon represents a simplified OCM ManagedClusterAddOn +type ManagedClusterAddon struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + InstallNamespace string `json:"installNamespace"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + Registrations []AddonRegistration `json:"registrations,omitempty"` + SupportedConfigs []AddonSupportedConfig `json:"supportedConfigs,omitempty"` +} diff --git a/dashboard/apiserver/pkg/models/addon_test.go b/dashboard/apiserver/pkg/models/addon_test.go new file mode 100644 index 00000000..ec85d483 --- /dev/null +++ b/dashboard/apiserver/pkg/models/addon_test.go @@ -0,0 +1,82 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddonRegistrationSubjectModel(t *testing.T) { + subject := AddonRegistrationSubject{ + Groups: []string{"system:masters", "admin"}, + User: "test-user", + } + + assert.Len(t, subject.Groups, 2) + assert.Contains(t, subject.Groups, "system:masters") + assert.Equal(t, "test-user", subject.User) +} + +func TestAddonRegistrationModel(t *testing.T) { + registration := AddonRegistration{ + SignerName: "test-signer", + Subject: AddonRegistrationSubject{ + Groups: []string{"admin"}, + User: "test-user", + }, + } + + assert.Equal(t, "test-signer", registration.SignerName) + assert.Equal(t, "test-user", registration.Subject.User) + assert.Len(t, registration.Subject.Groups, 1) +} + +func TestAddonSupportedConfigModel(t *testing.T) { + config := AddonSupportedConfig{ + Group: "addon.open-cluster-management.io", + Resource: "addonconfigs", + } + + assert.Equal(t, "addon.open-cluster-management.io", config.Group) + assert.Equal(t, "addonconfigs", config.Resource) +} + +func TestManagedClusterAddonModel(t *testing.T) { + addon := ManagedClusterAddon{ + ID: "test-id", + Name: "test-addon", + Namespace: "test-cluster", + InstallNamespace: "open-cluster-management-agent-addon", + Conditions: []Condition{ + { + Type: "Available", + Status: "True", + }, + }, + Registrations: []AddonRegistration{ + { + SignerName: "test-signer", + Subject: AddonRegistrationSubject{ + User: "test-user", + }, + }, + }, + SupportedConfigs: []AddonSupportedConfig{ + { + Group: "addon.open-cluster-management.io", + Resource: "addonconfigs", + }, + }, + } + + assert.Equal(t, "test-id", addon.ID) + assert.Equal(t, "test-addon", addon.Name) + assert.Equal(t, "test-cluster", addon.Namespace) + assert.Equal(t, "open-cluster-management-agent-addon", addon.InstallNamespace) + assert.Len(t, addon.Conditions, 1) + assert.Equal(t, "Available", addon.Conditions[0].Type) + assert.Len(t, addon.Registrations, 1) + assert.Equal(t, "test-signer", addon.Registrations[0].SignerName) + assert.Len(t, addon.SupportedConfigs, 1) + assert.Equal(t, "addon.open-cluster-management.io", addon.SupportedConfigs[0].Group) +} diff --git a/dashboard/apiserver/pkg/models/cluster.go b/dashboard/apiserver/pkg/models/cluster.go new file mode 100644 index 00000000..fc30c20d --- /dev/null +++ b/dashboard/apiserver/pkg/models/cluster.go @@ -0,0 +1,57 @@ +package models + +// ClusterClaim represents a claim from the managed cluster +type ClusterClaim struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// Taint represents a taint on the managed cluster +type Taint struct { + Key string `json:"key"` + Value string `json:"value,omitempty"` + Effect string `json:"effect"` +} + +// ClusterStatus represents a simplified cluster status +type ClusterStatus struct { + Available bool `json:"available"` + Joined bool `json:"joined"` + Conditions []Condition `json:"conditions,omitempty"` +} + +// ManagedClusterClientConfig represents the client configuration for a managed cluster +type ManagedClusterClientConfig struct { + URL string `json:"url"` + CABundle string `json:"caBundle,omitempty"` +} + +// Cluster represents a simplified OCM ManagedCluster +type Cluster struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // "Online", "Offline", etc. + Version string `json:"version,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + HubAccepted bool `json:"hubAccepted"` + Capacity map[string]string `json:"capacity,omitempty"` + Allocatable map[string]string `json:"allocatable,omitempty"` + ClusterClaims []ClusterClaim `json:"clusterClaims,omitempty"` + Taints []Taint `json:"taints,omitempty"` + ManagedClusterClientConfigs []ManagedClusterClientConfig `json:"managedClusterClientConfigs,omitempty"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} + +// LabelSelector represents a Kubernetes label selector +type LabelSelector struct { + MatchLabels map[string]string `json:"matchLabels,omitempty"` +} + +// ClusterSelector represents the selector for clusters in a ManagedClusterSet +type ClusterSelector struct { + SelectorType string `json:"selectorType"` + LabelSelector *LabelSelector `json:"labelSelector,omitempty"` +} + + diff --git a/dashboard/apiserver/pkg/models/cluster_test.go b/dashboard/apiserver/pkg/models/cluster_test.go new file mode 100644 index 00000000..4f166409 --- /dev/null +++ b/dashboard/apiserver/pkg/models/cluster_test.go @@ -0,0 +1,130 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClusterModel(t *testing.T) { + cluster := Cluster{ + ID: "test-id", + Name: "test-cluster", + Status: "Online", + Labels: map[string]string{ + "env": "test", + }, + Conditions: []Condition{ + { + Type: "Available", + Status: "True", + }, + }, + ClusterClaims: []ClusterClaim{ + { + Name: "platform", + Value: "AWS", + }, + }, + Taints: []Taint{ + { + Key: "test-key", + Value: "test-value", + Effect: "NoSchedule", + }, + }, + ManagedClusterClientConfigs: []ManagedClusterClientConfig{ + { + URL: "https://test-cluster:6443", + CABundle: "test-ca-bundle", + }, + }, + } + + assert.Equal(t, "test-id", cluster.ID) + assert.Equal(t, "test-cluster", cluster.Name) + assert.Equal(t, "Online", cluster.Status) + assert.Equal(t, "test", cluster.Labels["env"]) + assert.Len(t, cluster.Conditions, 1) + assert.Equal(t, "Available", cluster.Conditions[0].Type) + assert.Len(t, cluster.ClusterClaims, 1) + assert.Equal(t, "platform", cluster.ClusterClaims[0].Name) + assert.Len(t, cluster.Taints, 1) + assert.Equal(t, "test-key", cluster.Taints[0].Key) + assert.Len(t, cluster.ManagedClusterClientConfigs, 1) + assert.Equal(t, "https://test-cluster:6443", cluster.ManagedClusterClientConfigs[0].URL) +} + +func TestClusterClaimModel(t *testing.T) { + claim := ClusterClaim{ + Name: "test-claim", + Value: "test-value", + } + + assert.Equal(t, "test-claim", claim.Name) + assert.Equal(t, "test-value", claim.Value) +} + +func TestTaintModel(t *testing.T) { + taint := Taint{ + Key: "test-key", + Value: "test-value", + Effect: "NoSchedule", + } + + assert.Equal(t, "test-key", taint.Key) + assert.Equal(t, "test-value", taint.Value) + assert.Equal(t, "NoSchedule", taint.Effect) +} + +func TestClusterStatusModel(t *testing.T) { + status := ClusterStatus{ + Available: true, + Joined: true, + Conditions: []Condition{ + { + Type: "Available", + Status: "True", + }, + }, + } + + assert.True(t, status.Available) + assert.True(t, status.Joined) + assert.Len(t, status.Conditions, 1) +} + +func TestManagedClusterClientConfigModel(t *testing.T) { + config := ManagedClusterClientConfig{ + URL: "https://test-cluster:6443", + CABundle: "test-ca-bundle", + } + + assert.Equal(t, "https://test-cluster:6443", config.URL) + assert.Equal(t, "test-ca-bundle", config.CABundle) +} + +func TestLabelSelectorModel(t *testing.T) { + selector := LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + } + + assert.Equal(t, "prod", selector.MatchLabels["env"]) +} + +func TestClusterSelectorModel(t *testing.T) { + selector := ClusterSelector{ + SelectorType: "LabelSelector", + LabelSelector: &LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + } + + assert.Equal(t, "LabelSelector", selector.SelectorType) + assert.NotNil(t, selector.LabelSelector) + assert.Equal(t, "prod", selector.LabelSelector.MatchLabels["env"]) +} diff --git a/dashboard/apiserver/pkg/models/clusterset.go b/dashboard/apiserver/pkg/models/clusterset.go new file mode 100644 index 00000000..90aad91c --- /dev/null +++ b/dashboard/apiserver/pkg/models/clusterset.go @@ -0,0 +1,21 @@ +package models + +// ClusterSetSpec represents the spec of a ManagedClusterSet +type ClusterSetSpec struct { + ClusterSelector ClusterSelector `json:"clusterSelector"` +} + +// ClusterSetStatus represents the status of a ManagedClusterSet +type ClusterSetStatus struct { + Conditions []Condition `json:"conditions,omitempty"` +} + +// ClusterSet represents a simplified OCM ManagedClusterSet +type ClusterSet struct { + ID string `json:"id"` + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + Spec ClusterSetSpec `json:"spec,omitempty"` + Status ClusterSetStatus `json:"status,omitempty"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} diff --git a/dashboard/apiserver/pkg/models/clusterset_test.go b/dashboard/apiserver/pkg/models/clusterset_test.go new file mode 100644 index 00000000..f6abb2eb --- /dev/null +++ b/dashboard/apiserver/pkg/models/clusterset_test.go @@ -0,0 +1,84 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestClusterSetSpecModel(t *testing.T) { + spec := ClusterSetSpec{ + ClusterSelector: ClusterSelector{ + SelectorType: "LabelSelector", + LabelSelector: &LabelSelector{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + } + + assert.Equal(t, "LabelSelector", spec.ClusterSelector.SelectorType) + assert.NotNil(t, spec.ClusterSelector.LabelSelector) + assert.Equal(t, "prod", spec.ClusterSelector.LabelSelector.MatchLabels["env"]) +} + +func TestClusterSetStatusModel(t *testing.T) { + status := ClusterSetStatus{ + Conditions: []Condition{ + { + Type: "ClusterSetEmpty", + Status: "False", + Reason: "ClustersSelected", + }, + }, + } + + assert.Len(t, status.Conditions, 1) + assert.Equal(t, "ClusterSetEmpty", status.Conditions[0].Type) + assert.Equal(t, "False", status.Conditions[0].Status) + assert.Equal(t, "ClustersSelected", status.Conditions[0].Reason) +} + +func TestClusterSetModel(t *testing.T) { + clusterSet := ClusterSet{ + ID: "test-id", + Name: "test-clusterset", + Labels: map[string]string{ + "env": "test", + }, + Spec: ClusterSetSpec{ + ClusterSelector: ClusterSelector{ + SelectorType: "LabelSelector", + }, + }, + Status: ClusterSetStatus{ + Conditions: []Condition{ + { + Type: "ClusterSetEmpty", + Status: "False", + }, + }, + }, + CreationTimestamp: "2023-01-01T00:00:00Z", + } + + assert.Equal(t, "test-id", clusterSet.ID) + assert.Equal(t, "test-clusterset", clusterSet.Name) + assert.Equal(t, "test", clusterSet.Labels["env"]) + assert.Equal(t, "LabelSelector", clusterSet.Spec.ClusterSelector.SelectorType) + assert.Len(t, clusterSet.Status.Conditions, 1) + assert.Equal(t, "2023-01-01T00:00:00Z", clusterSet.CreationTimestamp) +} + +func TestClusterSetWithMinimalFields(t *testing.T) { + clusterSet := ClusterSet{ + ID: "minimal-id", + Name: "minimal-clusterset", + } + + assert.Equal(t, "minimal-id", clusterSet.ID) + assert.Equal(t, "minimal-clusterset", clusterSet.Name) + assert.Nil(t, clusterSet.Labels) + assert.Empty(t, clusterSet.CreationTimestamp) +} diff --git a/dashboard/apiserver/pkg/models/clustersetbinding.go b/dashboard/apiserver/pkg/models/clustersetbinding.go new file mode 100644 index 00000000..457a0869 --- /dev/null +++ b/dashboard/apiserver/pkg/models/clustersetbinding.go @@ -0,0 +1,35 @@ +package models + +// ManagedClusterSetBindingSpec represents the spec of a ManagedClusterSetBinding +type ManagedClusterSetBindingSpec struct { + // ClusterSet is the name of the ManagedClusterSet bound to the namespace + ClusterSet string `json:"clusterSet"` +} + +// ManagedClusterSetBindingStatus represents the status of a ManagedClusterSetBinding +type ManagedClusterSetBindingStatus struct { + // Conditions contains the different condition statuses for this ManagedClusterSetBinding + Conditions []Condition `json:"conditions,omitempty"` +} + +// ManagedClusterSetBinding represents a binding between a ManagedClusterSet and a namespace +// This allows resources in that namespace to use the ManagedClusterSet for placement +type ManagedClusterSetBinding struct { + // ID is a unique identifier for the ManagedClusterSetBinding + ID string `json:"id"` + + // Name is the name of the ManagedClusterSetBinding + Name string `json:"name"` + + // Namespace is the namespace where this binding exists + Namespace string `json:"namespace"` + + // Spec contains the binding specification + Spec ManagedClusterSetBindingSpec `json:"spec"` + + // Status contains the binding status + Status ManagedClusterSetBindingStatus `json:"status,omitempty"` + + // CreationTimestamp is the creation time of the binding + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} diff --git a/dashboard/apiserver/pkg/models/clustersetbinding_test.go b/dashboard/apiserver/pkg/models/clustersetbinding_test.go new file mode 100644 index 00000000..483961f7 --- /dev/null +++ b/dashboard/apiserver/pkg/models/clustersetbinding_test.go @@ -0,0 +1,55 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManagedClusterSetBindingModel(t *testing.T) { + binding := ManagedClusterSetBinding{ + ID: "test-id", + Name: "test-binding", + Namespace: "test-namespace", + Spec: ManagedClusterSetBindingSpec{ + ClusterSet: "test-clusterset", + }, + Status: ManagedClusterSetBindingStatus{ + Conditions: []Condition{ + { + Type: "Bound", + Status: "True", + Reason: "ClusterSetBound", + }, + }, + }, + CreationTimestamp: "2023-01-01T00:00:00Z", + } + + assert.Equal(t, "test-id", binding.ID) + assert.Equal(t, "test-binding", binding.Name) + assert.Equal(t, "test-namespace", binding.Namespace) + assert.Equal(t, "test-clusterset", binding.Spec.ClusterSet) + assert.Equal(t, "2023-01-01T00:00:00Z", binding.CreationTimestamp) + assert.Len(t, binding.Status.Conditions, 1) + assert.Equal(t, "Bound", binding.Status.Conditions[0].Type) + assert.Equal(t, "True", binding.Status.Conditions[0].Status) +} + +func TestManagedClusterSetBindingWithMinimalFields(t *testing.T) { + binding := ManagedClusterSetBinding{ + ID: "minimal-id", + Name: "minimal-binding", + Namespace: "default", + Spec: ManagedClusterSetBindingSpec{ + ClusterSet: "default-clusterset", + }, + } + + assert.Equal(t, "minimal-id", binding.ID) + assert.Equal(t, "minimal-binding", binding.Name) + assert.Equal(t, "default", binding.Namespace) + assert.Equal(t, "default-clusterset", binding.Spec.ClusterSet) + assert.Empty(t, binding.CreationTimestamp) + assert.Empty(t, binding.Status.Conditions) +} diff --git a/dashboard/apiserver/pkg/models/common.go b/dashboard/apiserver/pkg/models/common.go new file mode 100644 index 00000000..a9dfab2d --- /dev/null +++ b/dashboard/apiserver/pkg/models/common.go @@ -0,0 +1,10 @@ +package models + +// Condition represents the status condition of a cluster +type Condition struct { + Type string `json:"type"` + Status string `json:"status"` + LastTransitionTime string `json:"lastTransitionTime,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/dashboard/apiserver/pkg/models/common_test.go b/dashboard/apiserver/pkg/models/common_test.go new file mode 100644 index 00000000..bfeb33c1 --- /dev/null +++ b/dashboard/apiserver/pkg/models/common_test.go @@ -0,0 +1,36 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConditionModel(t *testing.T) { + condition := Condition{ + Type: "Available", + Status: "True", + LastTransitionTime: "2023-01-01T00:00:00Z", + Reason: "ClusterAvailable", + Message: "Cluster is available", + } + + assert.Equal(t, "Available", condition.Type) + assert.Equal(t, "True", condition.Status) + assert.Equal(t, "2023-01-01T00:00:00Z", condition.LastTransitionTime) + assert.Equal(t, "ClusterAvailable", condition.Reason) + assert.Equal(t, "Cluster is available", condition.Message) +} + +func TestConditionWithMinimalFields(t *testing.T) { + condition := Condition{ + Type: "Ready", + Status: "False", + } + + assert.Equal(t, "Ready", condition.Type) + assert.Equal(t, "False", condition.Status) + assert.Empty(t, condition.LastTransitionTime) + assert.Empty(t, condition.Reason) + assert.Empty(t, condition.Message) +} diff --git a/dashboard/apiserver/pkg/models/manifestwork.go b/dashboard/apiserver/pkg/models/manifestwork.go new file mode 100644 index 00000000..c604ba92 --- /dev/null +++ b/dashboard/apiserver/pkg/models/manifestwork.go @@ -0,0 +1,118 @@ +package models + +// ManifestWork represents a simplified version of the OCM ManifestWork resource +type ManifestWork struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Labels map[string]string `json:"labels,omitempty"` + Manifests []Manifest `json:"manifests,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + ResourceStatus ManifestResourceStatus `json:"resourceStatus,omitempty"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` +} + +// Manifest represents a resource to be deployed on a managed cluster +type Manifest struct { + RawExtension map[string]interface{} `json:"rawExtension,omitempty"` +} + +// ManifestResourceStatus represents the status of resources in a manifest work +type ManifestResourceStatus struct { + Manifests []ManifestCondition `json:"manifests,omitempty"` +} + +// ManifestCondition represents the conditions of resources deployed on a managed cluster +type ManifestCondition struct { + ResourceMeta ManifestResourceMeta `json:"resourceMeta"` + Conditions []Condition `json:"conditions"` +} + +// ManifestResourceMeta represents the metadata of a resource in a manifest +type ManifestResourceMeta struct { + Ordinal int32 `json:"ordinal"` + Group string `json:"group,omitempty"` + Version string `json:"version,omitempty"` + Kind string `json:"kind,omitempty"` + Resource string `json:"resource,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` +} + +// DeleteOption represents the deletion strategy when the manifestwork is deleted +type DeleteOption struct { + PropagationPolicy string `json:"propagationPolicy"` + SelectivelyOrphan *SelectivelyOrphan `json:"selectivelyOrphans,omitempty"` +} + +// SelectivelyOrphan represents resources following orphan deletion strategy +type SelectivelyOrphan struct { + OrphaningRules []OrphaningRule `json:"orphaningRules,omitempty"` +} + +// OrphaningRule identifies a single resource to be orphaned +type OrphaningRule struct { + Group string `json:"group,omitempty"` + Resource string `json:"resource"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +// ManifestWorkSpec represents the desired configuration of manifests to be deployed +type ManifestWorkSpec struct { + Workload []Manifest `json:"workload,omitempty"` + DeleteOption *DeleteOption `json:"deleteOption,omitempty"` + ManifestConfigs []ManifestConfigOption `json:"manifestConfigs,omitempty"` +} + +// ManifestConfigOption represents the configurations of a manifest +type ManifestConfigOption struct { + ResourceIdentifier ResourceIdentifier `json:"resourceIdentifier"` + FeedbackRules []FeedbackRule `json:"feedbackRules,omitempty"` + UpdateStrategy *UpdateStrategy `json:"updateStrategy,omitempty"` +} + +// ResourceIdentifier identifies a single resource +type ResourceIdentifier struct { + Group string `json:"group,omitempty"` + Resource string `json:"resource"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +// UpdateStrategy defines the strategy to update a manifest +type UpdateStrategy struct { + Type string `json:"type,omitempty"` + ServerSideApply *ServerSideApplyConfig `json:"serverSideApply,omitempty"` +} + +// ServerSideApplyConfig defines the server-side apply configuration +type ServerSideApplyConfig struct { + Force bool `json:"force"` + FieldManager string `json:"fieldManager,omitempty"` + IgnoreFields []IgnoreField `json:"ignoreFields,omitempty"` +} + +// IgnoreField defines the fields to be ignored during apply +type IgnoreField struct { + Condition string `json:"condition"` + JSONPaths []string `json:"jsonPaths"` +} + +// FeedbackRule defines how status can be returned +type FeedbackRule struct { + Type string `json:"type"` + JsonPaths []JsonPath `json:"jsonPaths,omitempty"` +} + +// JsonPath defines the json path of a field under status +type JsonPath struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` + Path string `json:"path"` +} + +// ManifestWorkList represents a list of ManifestWork objects +type ManifestWorkList struct { + Items []ManifestWork `json:"items"` +} diff --git a/dashboard/apiserver/pkg/models/manifestwork_test.go b/dashboard/apiserver/pkg/models/manifestwork_test.go new file mode 100644 index 00000000..9d7ac78e --- /dev/null +++ b/dashboard/apiserver/pkg/models/manifestwork_test.go @@ -0,0 +1,311 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestManifestWorkModel(t *testing.T) { + manifestWork := ManifestWork{ + ID: "test-id", + Name: "test-manifestwork", + Namespace: "test-cluster", + Labels: map[string]string{ + "app": "test-app", + }, + Manifests: []Manifest{ + { + RawExtension: map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": "test-config", + }, + }, + }, + }, + Conditions: []Condition{ + { + Type: "Applied", + Status: "True", + }, + }, + ResourceStatus: ManifestResourceStatus{ + Manifests: []ManifestCondition{ + { + ResourceMeta: ManifestResourceMeta{ + Ordinal: 0, + Group: "", + Version: "v1", + Kind: "ConfigMap", + Resource: "configmaps", + Name: "test-config", + Namespace: "default", + }, + Conditions: []Condition{ + { + Type: "Available", + Status: "True", + }, + }, + }, + }, + }, + CreationTimestamp: "2023-01-01T00:00:00Z", + } + + assert.Equal(t, "test-id", manifestWork.ID) + assert.Equal(t, "test-manifestwork", manifestWork.Name) + assert.Equal(t, "test-cluster", manifestWork.Namespace) + assert.Equal(t, "test-app", manifestWork.Labels["app"]) + assert.Len(t, manifestWork.Manifests, 1) + assert.Equal(t, "ConfigMap", manifestWork.Manifests[0].RawExtension["kind"]) + assert.Len(t, manifestWork.Conditions, 1) + assert.Equal(t, "Applied", manifestWork.Conditions[0].Type) + assert.Len(t, manifestWork.ResourceStatus.Manifests, 1) + assert.Equal(t, "test-config", manifestWork.ResourceStatus.Manifests[0].ResourceMeta.Name) + assert.Equal(t, "2023-01-01T00:00:00Z", manifestWork.CreationTimestamp) +} + +func TestManifestModel(t *testing.T) { + manifest := Manifest{ + RawExtension: map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": "test-deployment", + "namespace": "default", + }, + "spec": map[string]interface{}{ + "replicas": 3, + }, + }, + } + + assert.Equal(t, "apps/v1", manifest.RawExtension["apiVersion"]) + assert.Equal(t, "Deployment", manifest.RawExtension["kind"]) + metadata := manifest.RawExtension["metadata"].(map[string]interface{}) + assert.Equal(t, "test-deployment", metadata["name"]) +} + +func TestManifestResourceStatusModel(t *testing.T) { + status := ManifestResourceStatus{ + Manifests: []ManifestCondition{ + { + ResourceMeta: ManifestResourceMeta{ + Ordinal: 0, + Kind: "Service", + Name: "test-service", + Resource: "services", + }, + Conditions: []Condition{ + { + Type: "Available", + Status: "True", + }, + }, + }, + }, + } + + assert.Len(t, status.Manifests, 1) + assert.Equal(t, "Service", status.Manifests[0].ResourceMeta.Kind) + assert.Equal(t, "test-service", status.Manifests[0].ResourceMeta.Name) + assert.Len(t, status.Manifests[0].Conditions, 1) +} + +func TestManifestConditionModel(t *testing.T) { + condition := ManifestCondition{ + ResourceMeta: ManifestResourceMeta{ + Ordinal: 1, + Group: "apps", + Version: "v1", + Kind: "Deployment", + Resource: "deployments", + Name: "test-deployment", + Namespace: "default", + }, + Conditions: []Condition{ + { + Type: "Progressing", + Status: "True", + Reason: "NewReplicaSetAvailable", + Message: "ReplicaSet has successfully progressed", + }, + }, + } + + assert.Equal(t, int32(1), condition.ResourceMeta.Ordinal) + assert.Equal(t, "apps", condition.ResourceMeta.Group) + assert.Equal(t, "v1", condition.ResourceMeta.Version) + assert.Equal(t, "Deployment", condition.ResourceMeta.Kind) + assert.Equal(t, "deployments", condition.ResourceMeta.Resource) + assert.Equal(t, "test-deployment", condition.ResourceMeta.Name) + assert.Equal(t, "default", condition.ResourceMeta.Namespace) + assert.Len(t, condition.Conditions, 1) + assert.Equal(t, "Progressing", condition.Conditions[0].Type) +} + +func TestManifestResourceMetaModel(t *testing.T) { + meta := ManifestResourceMeta{ + Ordinal: 0, + Group: "networking.k8s.io", + Version: "v1", + Kind: "Ingress", + Resource: "ingresses", + Name: "test-ingress", + Namespace: "default", + } + + assert.Equal(t, int32(0), meta.Ordinal) + assert.Equal(t, "networking.k8s.io", meta.Group) + assert.Equal(t, "v1", meta.Version) + assert.Equal(t, "Ingress", meta.Kind) + assert.Equal(t, "ingresses", meta.Resource) + assert.Equal(t, "test-ingress", meta.Name) + assert.Equal(t, "default", meta.Namespace) +} + +func TestDeleteOptionModel(t *testing.T) { + deleteOption := DeleteOption{ + PropagationPolicy: "Foreground", + SelectivelyOrphan: &SelectivelyOrphan{ + OrphaningRules: []OrphaningRule{ + { + Group: "apps", + Resource: "deployments", + Name: "keep-deployment", + Namespace: "default", + }, + }, + }, + } + + assert.Equal(t, "Foreground", deleteOption.PropagationPolicy) + assert.NotNil(t, deleteOption.SelectivelyOrphan) + assert.Len(t, deleteOption.SelectivelyOrphan.OrphaningRules, 1) + assert.Equal(t, "apps", deleteOption.SelectivelyOrphan.OrphaningRules[0].Group) + assert.Equal(t, "keep-deployment", deleteOption.SelectivelyOrphan.OrphaningRules[0].Name) +} + +func TestOrphaningRuleModel(t *testing.T) { + rule := OrphaningRule{ + Group: "v1", + Resource: "secrets", + Name: "important-secret", + Namespace: "kube-system", + } + + assert.Equal(t, "v1", rule.Group) + assert.Equal(t, "secrets", rule.Resource) + assert.Equal(t, "important-secret", rule.Name) + assert.Equal(t, "kube-system", rule.Namespace) +} + +func TestManifestConfigOptionModel(t *testing.T) { + config := ManifestConfigOption{ + ResourceIdentifier: ResourceIdentifier{ + Group: "apps", + Resource: "deployments", + Name: "test-deployment", + Namespace: "default", + }, + FeedbackRules: []FeedbackRule{ + { + Type: "JSONPaths", + JsonPaths: []JsonPath{ + { + Name: "status", + Version: "v1", + Path: ".status.readyReplicas", + }, + }, + }, + }, + UpdateStrategy: &UpdateStrategy{ + Type: "ServerSideApply", + ServerSideApply: &ServerSideApplyConfig{ + Force: true, + FieldManager: "manifestwork-agent", + }, + }, + } + + assert.Equal(t, "apps", config.ResourceIdentifier.Group) + assert.Equal(t, "test-deployment", config.ResourceIdentifier.Name) + assert.Len(t, config.FeedbackRules, 1) + assert.Equal(t, "JSONPaths", config.FeedbackRules[0].Type) + assert.NotNil(t, config.UpdateStrategy) + assert.Equal(t, "ServerSideApply", config.UpdateStrategy.Type) + assert.True(t, config.UpdateStrategy.ServerSideApply.Force) +} + +func TestResourceIdentifierModel(t *testing.T) { + identifier := ResourceIdentifier{ + Group: "batch", + Resource: "jobs", + Name: "test-job", + Namespace: "default", + } + + assert.Equal(t, "batch", identifier.Group) + assert.Equal(t, "jobs", identifier.Resource) + assert.Equal(t, "test-job", identifier.Name) + assert.Equal(t, "default", identifier.Namespace) +} + +func TestFeedbackRuleModel(t *testing.T) { + rule := FeedbackRule{ + Type: "JSONPaths", + JsonPaths: []JsonPath{ + { + Name: "replicas", + Version: "v1", + Path: ".status.replicas", + }, + { + Name: "ready", + Path: ".status.readyReplicas", + }, + }, + } + + assert.Equal(t, "JSONPaths", rule.Type) + assert.Len(t, rule.JsonPaths, 2) + assert.Equal(t, "replicas", rule.JsonPaths[0].Name) + assert.Equal(t, ".status.replicas", rule.JsonPaths[0].Path) + assert.Equal(t, "ready", rule.JsonPaths[1].Name) +} + +func TestJsonPathModel(t *testing.T) { + jsonPath := JsonPath{ + Name: "conditions", + Version: "v1", + Path: ".status.conditions", + } + + assert.Equal(t, "conditions", jsonPath.Name) + assert.Equal(t, "v1", jsonPath.Version) + assert.Equal(t, ".status.conditions", jsonPath.Path) +} + +func TestManifestWorkListModel(t *testing.T) { + list := ManifestWorkList{ + Items: []ManifestWork{ + { + ID: "work1", + Name: "manifestwork-1", + }, + { + ID: "work2", + Name: "manifestwork-2", + }, + }, + } + + assert.Len(t, list.Items, 2) + assert.Equal(t, "work1", list.Items[0].ID) + assert.Equal(t, "manifestwork-1", list.Items[0].Name) + assert.Equal(t, "work2", list.Items[1].ID) +} diff --git a/dashboard/apiserver/pkg/models/placement.go b/dashboard/apiserver/pkg/models/placement.go new file mode 100644 index 00000000..cfa19253 --- /dev/null +++ b/dashboard/apiserver/pkg/models/placement.go @@ -0,0 +1,138 @@ +package models + +// MatchExpression represents a label/claim expression for a Placement +type MatchExpression struct { + Key string `json:"key"` + Operator string `json:"operator"` + Values []string `json:"values,omitempty"` +} + +// LabelSelectorWithExpressions represents a label selector with expressions +type LabelSelectorWithExpressions struct { + MatchLabels map[string]string `json:"matchLabels,omitempty"` + MatchExpressions []MatchExpression `json:"matchExpressions,omitempty"` +} + +// ClaimSelectorWithExpressions represents a claim selector with expressions +type ClaimSelectorWithExpressions struct { + MatchExpressions []MatchExpression `json:"matchExpressions,omitempty"` +} + +// CelSelectorWithExpressions represents a CEL selector with expressions +type CelSelectorWithExpressions struct { + CelExpressions []string `json:"celExpressions,omitempty"` +} + +// RequiredClusterSelector represents required cluster selector +type RequiredClusterSelector struct { + LabelSelector *LabelSelectorWithExpressions `json:"labelSelector,omitempty"` + ClaimSelector *ClaimSelectorWithExpressions `json:"claimSelector,omitempty"` + CelSelector *CelSelectorWithExpressions `json:"celSelector,omitempty"` +} + +// Predicate represents a placement predicate +type Predicate struct { + RequiredClusterSelector *RequiredClusterSelector `json:"requiredClusterSelector,omitempty"` +} + +// AddOnScore represents addon score configuration +type AddOnScore struct { + ResourceName string `json:"resourceName"` + ScoreName string `json:"scoreName"` +} + +// ScoreCoordinate represents score coordinate +type ScoreCoordinate struct { + Type string `json:"type,omitempty"` + BuiltIn string `json:"builtIn,omitempty"` + AddOn *AddOnScore `json:"addOn,omitempty"` +} + +// PrioritizerConfig represents the configuration of a prioritizer +type PrioritizerConfig struct { + ScoreCoordinate *ScoreCoordinate `json:"scoreCoordinate,omitempty"` + Weight int32 `json:"weight,omitempty"` +} + +// PrioritizerPolicy represents prioritizer policy +type PrioritizerPolicy struct { + Mode string `json:"mode,omitempty"` + Configurations []PrioritizerConfig `json:"configurations,omitempty"` +} + +// GroupClusterSelector represents a selector for a group of clusters +type GroupClusterSelector struct { + LabelSelector *LabelSelectorWithExpressions `json:"labelSelector,omitempty"` +} + +// DecisionGroup represents a group in decision strategy +type DecisionGroup struct { + GroupName string `json:"groupName,omitempty"` + GroupClusterSelector GroupClusterSelector `json:"groupClusterSelector,omitempty"` +} + +// GroupStrategy represents group strategy for decisions +type GroupStrategy struct { + DecisionGroups []DecisionGroup `json:"decisionGroups,omitempty"` + ClustersPerDecisionGroup string `json:"clustersPerDecisionGroup,omitempty"` +} + +// DecisionStrategy represents strategy for placement decisions +type DecisionStrategy struct { + GroupStrategy GroupStrategy `json:"groupStrategy,omitempty"` +} + +// PlacementToleration represents a toleration for placement +type PlacementToleration struct { + Key string `json:"key,omitempty"` + Operator string `json:"operator,omitempty"` + Value string `json:"value,omitempty"` + Effect string `json:"effect,omitempty"` + TolerationSeconds *int64 `json:"tolerationSeconds,omitempty"` +} + +// DecisionGroupStatus represents status of a decision group +type DecisionGroupStatus struct { + DecisionGroupIndex int32 `json:"decisionGroupIndex"` + DecisionGroupName string `json:"decisionGroupName,omitempty"` + Decisions []string `json:"decisions,omitempty"` + ClusterCount int32 `json:"clusterCount"` +} + +// Placement represents a simplified OCM Placement +type Placement struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + CreationTimestamp string `json:"creationTimestamp,omitempty"` + ClusterSets []string `json:"clusterSets,omitempty"` + NumberOfClusters *int32 `json:"numberOfClusters,omitempty"` + Predicates []Predicate `json:"predicates,omitempty"` + PrioritizerPolicy *PrioritizerPolicy `json:"prioritizerPolicy,omitempty"` + Tolerations []PlacementToleration `json:"tolerations,omitempty"` + DecisionStrategy *DecisionStrategy `json:"decisionStrategy,omitempty"` + NumberOfSelectedClusters int32 `json:"numberOfSelectedClusters"` + DecisionGroups []DecisionGroupStatus `json:"decisionGroups,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + Satisfied bool `json:"satisfied"` + ReasonMessage string `json:"reasonMessage,omitempty"` +} + +// ClusterDecision represents a single cluster decision +type ClusterDecision struct { + ClusterName string `json:"clusterName"` + Reason string `json:"reason"` +} + +// PlacementDecision represents a simplified OCM PlacementDecision +type PlacementDecision struct { + ID string `json:"id"` + Name string `json:"name"` + Namespace string `json:"namespace"` + Decisions []ClusterDecision `json:"decisions,omitempty"` +} + +// Helper function to create a pointer to an int32 +func IntPtr(i int32) *int32 { + return &i +} diff --git a/dashboard/apiserver/pkg/models/placement_test.go b/dashboard/apiserver/pkg/models/placement_test.go new file mode 100644 index 00000000..4c997e61 --- /dev/null +++ b/dashboard/apiserver/pkg/models/placement_test.go @@ -0,0 +1,166 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIntPtr(t *testing.T) { + value := int32(42) + ptr := IntPtr(value) + + assert.NotNil(t, ptr) + assert.Equal(t, value, *ptr) +} + +func TestMatchExpressionModel(t *testing.T) { + expr := MatchExpression{ + Key: "region", + Operator: "In", + Values: []string{"us-east-1", "us-west-2"}, + } + + assert.Equal(t, "region", expr.Key) + assert.Equal(t, "In", expr.Operator) + assert.Len(t, expr.Values, 2) + assert.Contains(t, expr.Values, "us-east-1") +} + +func TestLabelSelectorWithExpressionsModel(t *testing.T) { + selector := LabelSelectorWithExpressions{ + MatchLabels: map[string]string{ + "env": "prod", + }, + MatchExpressions: []MatchExpression{ + { + Key: "region", + Operator: "In", + Values: []string{"us-east-1"}, + }, + }, + } + + assert.Equal(t, "prod", selector.MatchLabels["env"]) + assert.Len(t, selector.MatchExpressions, 1) + assert.Equal(t, "region", selector.MatchExpressions[0].Key) +} + +func TestClaimSelectorWithExpressionsModel(t *testing.T) { + selector := ClaimSelectorWithExpressions{ + MatchExpressions: []MatchExpression{ + { + Key: "platform", + Operator: "In", + Values: []string{"AWS"}, + }, + }, + } + + assert.Len(t, selector.MatchExpressions, 1) + assert.Equal(t, "platform", selector.MatchExpressions[0].Key) +} + +func TestRequiredClusterSelectorModel(t *testing.T) { + selector := RequiredClusterSelector{ + LabelSelector: &LabelSelectorWithExpressions{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + ClaimSelector: &ClaimSelectorWithExpressions{ + MatchExpressions: []MatchExpression{ + { + Key: "platform", + Operator: "In", + Values: []string{"AWS"}, + }, + }, + }, + } + + assert.NotNil(t, selector.LabelSelector) + assert.NotNil(t, selector.ClaimSelector) + assert.Equal(t, "prod", selector.LabelSelector.MatchLabels["env"]) + assert.Len(t, selector.ClaimSelector.MatchExpressions, 1) +} + +func TestPlacementModel(t *testing.T) { + numberOfClusters := int32(3) + placement := Placement{ + ID: "test-id", + Name: "test-placement", + Namespace: "test-namespace", + ClusterSets: []string{"clusterset1"}, + NumberOfClusters: &numberOfClusters, + Predicates: []Predicate{ + { + RequiredClusterSelector: &RequiredClusterSelector{ + LabelSelector: &LabelSelectorWithExpressions{ + MatchLabels: map[string]string{ + "env": "prod", + }, + }, + }, + }, + }, + NumberOfSelectedClusters: 2, + Satisfied: true, + } + + assert.Equal(t, "test-id", placement.ID) + assert.Equal(t, "test-placement", placement.Name) + assert.Equal(t, "test-namespace", placement.Namespace) + assert.Len(t, placement.ClusterSets, 1) + assert.Equal(t, "clusterset1", placement.ClusterSets[0]) + assert.NotNil(t, placement.NumberOfClusters) + assert.Equal(t, int32(3), *placement.NumberOfClusters) + assert.Len(t, placement.Predicates, 1) + assert.Equal(t, int32(2), placement.NumberOfSelectedClusters) + assert.True(t, placement.Satisfied) +} + +func TestPlacementDecisionModel(t *testing.T) { + decision := PlacementDecision{ + ID: "test-id", + Name: "test-decision", + Namespace: "test-namespace", + Decisions: []ClusterDecision{ + { + ClusterName: "cluster1", + Reason: "Selected", + }, + }, + } + + assert.Equal(t, "test-id", decision.ID) + assert.Equal(t, "test-decision", decision.Name) + assert.Equal(t, "test-namespace", decision.Namespace) + assert.Len(t, decision.Decisions, 1) + assert.Equal(t, "cluster1", decision.Decisions[0].ClusterName) + assert.Equal(t, "Selected", decision.Decisions[0].Reason) +} + +func TestClusterDecisionModel(t *testing.T) { + decision := ClusterDecision{ + ClusterName: "test-cluster", + Reason: "Available", + } + + assert.Equal(t, "test-cluster", decision.ClusterName) + assert.Equal(t, "Available", decision.Reason) +} + +func TestDecisionGroupStatusModel(t *testing.T) { + group := DecisionGroupStatus{ + DecisionGroupIndex: 0, + DecisionGroupName: "group1", + Decisions: []string{"cluster1", "cluster2"}, + ClusterCount: 2, + } + + assert.Equal(t, int32(0), group.DecisionGroupIndex) + assert.Equal(t, "group1", group.DecisionGroupName) + assert.Len(t, group.Decisions, 2) + assert.Equal(t, int32(2), group.ClusterCount) +} diff --git a/dashboard/apiserver/pkg/models/placementdecision_test.go b/dashboard/apiserver/pkg/models/placementdecision_test.go new file mode 100644 index 00000000..a78297ea --- /dev/null +++ b/dashboard/apiserver/pkg/models/placementdecision_test.go @@ -0,0 +1,58 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPlacementDecisionModelDetailed(t *testing.T) { + decision := PlacementDecision{ + ID: "test-id", + Name: "test-decision", + Namespace: "test-namespace", + Decisions: []ClusterDecision{ + { + ClusterName: "cluster1", + Reason: "Selected", + }, + { + ClusterName: "cluster2", + Reason: "Available", + }, + }, + } + + assert.Equal(t, "test-id", decision.ID) + assert.Equal(t, "test-decision", decision.Name) + assert.Equal(t, "test-namespace", decision.Namespace) + assert.Len(t, decision.Decisions, 2) + assert.Equal(t, "cluster1", decision.Decisions[0].ClusterName) + assert.Equal(t, "Selected", decision.Decisions[0].Reason) + assert.Equal(t, "cluster2", decision.Decisions[1].ClusterName) + assert.Equal(t, "Available", decision.Decisions[1].Reason) +} + +func TestPlacementDecisionWithEmptyDecisions(t *testing.T) { + decision := PlacementDecision{ + ID: "empty-id", + Name: "empty-decision", + Namespace: "default", + Decisions: []ClusterDecision{}, + } + + assert.Equal(t, "empty-id", decision.ID) + assert.Equal(t, "empty-decision", decision.Name) + assert.Equal(t, "default", decision.Namespace) + assert.Empty(t, decision.Decisions) +} + +func TestClusterDecisionModelDetailed(t *testing.T) { + decision := ClusterDecision{ + ClusterName: "production-cluster", + Reason: "MatchesRequirements", + } + + assert.Equal(t, "production-cluster", decision.ClusterName) + assert.Equal(t, "MatchesRequirements", decision.Reason) +} diff --git a/dashboard/apiserver/pkg/server/server.go b/dashboard/apiserver/pkg/server/server.go new file mode 100644 index 00000000..a5634603 --- /dev/null +++ b/dashboard/apiserver/pkg/server/server.go @@ -0,0 +1,257 @@ +package server + +import ( + "context" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" + + "open-cluster-management-io/lab/apiserver/pkg/client" + "open-cluster-management-io/lab/apiserver/pkg/handlers" + + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// validateToken validates a Bearer token using Kubernetes TokenReview API +func validateToken(token string, ocmClient *client.OCMClient, ctx context.Context) bool { + if ocmClient == nil || ocmClient.KubernetesClient == nil { + log.Println("OCM client or Kubernetes client is nil") + return false + } + + // Create TokenReview request + tokenReview := &authv1.TokenReview{ + Spec: authv1.TokenReviewSpec{ + Token: token, + }, + } + + // Send TokenReview to Kubernetes API + result, err := ocmClient.KubernetesClient.AuthenticationV1().TokenReviews().Create(ctx, tokenReview, metav1.CreateOptions{}) + if err != nil { + log.Printf("TokenReview API call failed: %v", err) + return false + } + + // Check if token is authenticated + if !result.Status.Authenticated { + log.Printf("Token not authenticated: %s", result.Status.Error) + return false + } + + log.Printf("Token authenticated for user: %s", result.Status.User.Username) + return true +} + +// min returns the minimum of two integers +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// SetupServer initializes the HTTP server with all required routes +func SetupServer(ocmClient *client.OCMClient, ctx context.Context, debugMode bool) *gin.Engine { + // Check if debug mode is enabled + if debugMode { + log.Println("Debug mode enabled") + gin.SetMode(gin.DebugMode) + } else { + gin.SetMode(gin.ReleaseMode) + } + + // Set up Gin router + r := gin.Default() + + // Configure CORS + r.Use(cors.New(cors.Config{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{"GET", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // API routes + api := r.Group("/api") + { + // Enhanced authorization middleware with TokenReview validation + authMiddleware := func(c *gin.Context) { + // Check if authentication is bypassed + if os.Getenv("DASHBOARD_BYPASS_AUTH") == "true" { + log.Println("Authentication bypassed (DASHBOARD_BYPASS_AUTH=true)") + c.Next() + return + } + + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + log.Println("Authorization header missing") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"}) + c.Abort() + return + } + + // Extract token from "Bearer " format + tokenParts := strings.Split(authHeader, " ") + if len(tokenParts) != 2 || tokenParts[0] != "Bearer" { + log.Println("Invalid authorization header format") + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format. Expected: Bearer "}) + c.Abort() + return + } + + token := tokenParts[1] + + // Validate token using Kubernetes TokenReview API + if !validateToken(token, ocmClient, ctx) { + log.Printf("Token validation failed for token: %s...", token[:min(len(token), 10)]) + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + c.Abort() + return + } + + log.Println("Token validation successful") + c.Next() + } + + // Register cluster routes + api.GET("/clusters", authMiddleware, func(c *gin.Context) { + handlers.GetClusters(c, ocmClient, ctx) + }) + + api.GET("/clusters/:name", authMiddleware, func(c *gin.Context) { + handlers.GetCluster(c, ocmClient, ctx) + }) + + // Register cluster addon routes + api.GET("/clusters/:name/addons", authMiddleware, func(c *gin.Context) { + handlers.GetClusterAddons(c, ocmClient, ctx) + }) + + api.GET("/clusters/:name/addons/:addonName", authMiddleware, func(c *gin.Context) { + handlers.GetClusterAddon(c, ocmClient, ctx) + }) + + // Register clusterset routes + api.GET("/clustersets", authMiddleware, func(c *gin.Context) { + handlers.GetClusterSets(c, ocmClient, ctx) + }) + + api.GET("/clustersets/:name", authMiddleware, func(c *gin.Context) { + handlers.GetClusterSet(c, ocmClient, ctx) + }) + + // Register clustersetbinding routes + api.GET("/clustersetbindings", authMiddleware, func(c *gin.Context) { + handlers.GetAllClusterSetBindings(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/clustersetbindings", authMiddleware, func(c *gin.Context) { + handlers.GetClusterSetBindings(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/clustersetbindings/:name", authMiddleware, func(c *gin.Context) { + handlers.GetClusterSetBinding(c, ocmClient, ctx) + }) + + // Register manifestwork routes + api.GET("/namespaces/:namespace/manifestworks", authMiddleware, func(c *gin.Context) { + handlers.GetManifestWorks(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/manifestworks/:name", authMiddleware, func(c *gin.Context) { + handlers.GetManifestWork(c, ocmClient, ctx) + }) + + // Register placement routes + api.GET("/placements", authMiddleware, func(c *gin.Context) { + handlers.GetPlacements(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placements", authMiddleware, func(c *gin.Context) { + handlers.GetPlacementsByNamespace(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placements/:name", authMiddleware, func(c *gin.Context) { + handlers.GetPlacement(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placements/:name/decisions", authMiddleware, func(c *gin.Context) { + handlers.GetPlacementDecisions(c, ocmClient, ctx) + }) + + // Register placementdecision routes + api.GET("/placementdecisions", authMiddleware, func(c *gin.Context) { + handlers.GetAllPlacementDecisions(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placementdecisions", authMiddleware, func(c *gin.Context) { + handlers.GetPlacementDecisionsByNamespace(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placementdecisions/:name", authMiddleware, func(c *gin.Context) { + handlers.GetPlacementDecision(c, ocmClient, ctx) + }) + + api.GET("/namespaces/:namespace/placements/:name/placementdecisions", authMiddleware, func(c *gin.Context) { + handlers.GetPlacementDecisionsByPlacement(c, ocmClient, ctx) + }) + + // Register streaming routes + api.GET("/stream/clusters", authMiddleware, func(c *gin.Context) { + handlers.StreamClusters(c, ocmClient.Interface, ctx) + }) + } + + // Add health check endpoint (no authentication required) + r.GET("/health", func(c *gin.Context) { + // Simple health check - you can add more sophisticated checks here + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + }) + + // Alternative health check endpoint following Kubernetes conventions + r.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + }) + }) + + // API status endpoint + r.GET("/", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "OCM Dashboard API Server", + "version": "v0.0.1", + "status": "running", + "endpoints": gin.H{ + "health": "/health", + "healthz": "/healthz", + "api": "/api/*", + }, + }) + }) + + return r +} + +// RunServer starts the HTTP server on the specified port +func RunServer(r *gin.Engine) { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + log.Printf("Starting server on port %s", port) + r.Run(":" + port) +} diff --git a/dashboard/apiserver/pkg/server/server_test.go b/dashboard/apiserver/pkg/server/server_test.go new file mode 100644 index 00000000..1700c7cb --- /dev/null +++ b/dashboard/apiserver/pkg/server/server_test.go @@ -0,0 +1,183 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" + + "open-cluster-management-io/lab/apiserver/pkg/client" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestSetupServer(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + debugMode bool + client *client.OCMClient + }{ + { + name: "debug mode enabled", + debugMode: true, + client: nil, + }, + { + name: "debug mode disabled", + debugMode: false, + client: nil, + }, + { + name: "with client", + debugMode: false, + client: &client.OCMClient{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + router := SetupServer(tt.client, ctx, tt.debugMode) + + assert.NotNil(t, router) + + routes := router.Routes() + assert.NotEmpty(t, routes) + + foundAPIRoutes := false + for _, route := range routes { + if route.Path == "/api/clusters" { + foundAPIRoutes = true + break + } + } + assert.True(t, foundAPIRoutes, "API routes should be registered") + }) + } +} + +func TestRunServer(t *testing.T) { + tests := []struct { + name string + port string + }{ + { + name: "default port", + port: "", + }, + { + name: "custom port", + port: "9090", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.port != "" { + os.Setenv("PORT", tt.port) + defer os.Unsetenv("PORT") + } + + router := gin.New() + router.GET("/test", func(c *gin.Context) { + c.JSON(200, gin.H{"status": "ok"}) + }) + + assert.NotPanics(t, func() { + go func() { + defer func() { + if r := recover(); r != nil { + if r != "test exit" { + panic(r) + } + } + }() + RunServer(router) + }() + }) + }) + } +} + +func TestAuthMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + tests := []struct { + name string + bypassAuth string + authHeader string + expectedStatus int + }{ + { + name: "bypass auth enabled", + bypassAuth: "true", + authHeader: "", + expectedStatus: http.StatusInternalServerError, + }, + { + name: "bypass auth disabled with header", + bypassAuth: "false", + authHeader: "Bearer token", + expectedStatus: http.StatusInternalServerError, + }, + { + name: "bypass auth disabled without header", + bypassAuth: "false", + authHeader: "", + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("DASHBOARD_BYPASS_AUTH", tt.bypassAuth) + defer os.Unsetenv("DASHBOARD_BYPASS_AUTH") + + ctx := context.Background() + router := SetupServer(nil, ctx, false) + + req, _ := http.NewRequest("GET", "/api/clusters", nil) + if tt.authHeader != "" { + req.Header.Set("Authorization", tt.authHeader) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, tt.expectedStatus, w.Code) + }) + } +} + +func TestCORSConfiguration(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + router := SetupServer(nil, ctx, false) + + req, _ := http.NewRequest("OPTIONS", "/api/clusters", nil) + req.Header.Set("Origin", "http://localhost:3000") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, "*", w.Header().Get("Access-Control-Allow-Origin")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Methods"), "GET") + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "Authorization") +} + +func TestRootRedirect(t *testing.T) { + gin.SetMode(gin.TestMode) + ctx := context.Background() + router := SetupServer(nil, ctx, false) + + req, _ := http.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusMovedPermanently, w.Code) + assert.Equal(t, "/static/index.html", w.Header().Get("Location")) +} diff --git a/dashboard/apiserver/run-dev.sh b/dashboard/apiserver/run-dev.sh new file mode 100755 index 00000000..b80573ac --- /dev/null +++ b/dashboard/apiserver/run-dev.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Use colored output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}===== OCM Dashboard Development Mode =====${NC}" + +# Set development environment variables +export DASHBOARD_DEBUG=true +export DASHBOARD_BYPASS_AUTH=true +export DASHBOARD_USE_MOCK=true + +echo -e "${YELLOW}Environment variables set:${NC}" +echo "DASHBOARD_DEBUG=true - Enable debug logging" +echo "DASHBOARD_BYPASS_AUTH=true - Skip authentication checks" +echo "DASHBOARD_USE_MOCK=true - Use mock data instead of real clusters" +echo -e "" + +# Get the script's directory and change to it +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +cd "$SCRIPT_DIR" + +echo -e "${GREEN}Starting backend service...${NC}" +echo "API will be available at http://localhost:8080" + +# Run from the backend directory +go run main.go $@ \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/.helmignore b/dashboard/charts/ocm-dashboard/.helmignore new file mode 100644 index 00000000..0e8a0eb3 --- /dev/null +++ b/dashboard/charts/ocm-dashboard/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/dashboard/charts/ocm-dashboard/Chart.yaml b/dashboard/charts/ocm-dashboard/Chart.yaml new file mode 100644 index 00000000..b2e8cf6e --- /dev/null +++ b/dashboard/charts/ocm-dashboard/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: ocm-dashboard +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/dashboard/charts/ocm-dashboard/templates/_helpers.tpl b/dashboard/charts/ocm-dashboard/templates/_helpers.tpl new file mode 100644 index 00000000..366c3c3f --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/_helpers.tpl @@ -0,0 +1,72 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "ocm-dashboard.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "ocm-dashboard.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "ocm-dashboard.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "ocm-dashboard.labels" -}} +helm.sh/chart: {{ include "ocm-dashboard.chart" . }} +{{ include "ocm-dashboard.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "ocm-dashboard.selectorLabels" -}} +app.kubernetes.io/name: {{ include "ocm-dashboard.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "ocm-dashboard.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "ocm-dashboard.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create the image name +*/}} +{{- define "ocm-dashboard.image" -}} +{{- $registry := .Values.image.registry -}} +{{- $repository := .Values.image.repository -}} +{{- $tag := .Values.image.tag | default .Chart.AppVersion -}} +{{- printf "%s/%s:%s" $registry $repository $tag -}} +{{- end }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/templates/deployment.yaml b/dashboard/charts/ocm-dashboard/templates/deployment.yaml new file mode 100644 index 00000000..f75c250f --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/deployment.yaml @@ -0,0 +1,90 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "ocm-dashboard.fullname" . }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} +spec: + {{- if not .Values.api.autoscaling.enabled }} + replicas: {{ .Values.api.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "ocm-dashboard.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "ocm-dashboard.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "ocm-dashboard.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + # API Container + - name: api + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.api.image.registry }}/{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.api.image.pullPolicy }} + ports: + - name: api + containerPort: {{ .Values.api.service.targetPort }} + protocol: TCP + env: + {{- range $key, $value := .Values.api.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + {{- with .Values.api.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + livenessProbe: + {{- toYaml .Values.api.livenessProbe | nindent 12 }} + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + {{- with .Values.volumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + # UI Container + - name: ui + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.ui.image.registry }}/{{ .Values.ui.image.repository }}:{{ .Values.ui.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.ui.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.ui.service.targetPort }} + protocol: TCP + livenessProbe: + {{- toYaml .Values.ui.livenessProbe | nindent 12 }} + resources: + {{- toYaml .Values.ui.resources | nindent 12 }} + {{- with .Values.uiVolumeMounts }} + volumeMounts: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.volumes }} + volumes: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/templates/ingress.yaml b/dashboard/charts/ocm-dashboard/templates/ingress.yaml new file mode 100644 index 00000000..f538d781 --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/ingress.yaml @@ -0,0 +1,60 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "ocm-dashboard.fullname" . -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ .backend.service.name | default $fullName }} + port: + number: {{ .backend.service.port.number }} + {{- else }} + serviceName: {{ .backend.service.name | default $fullName }} + servicePort: {{ .backend.service.port.number }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/templates/rbac.yaml b/dashboard/charts/ocm-dashboard/templates/rbac.yaml new file mode 100644 index 00000000..d9171fe7 --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/rbac.yaml @@ -0,0 +1,48 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "ocm-dashboard.fullname" . }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} +rules: + # OCM Resources + - apiGroups: ["cluster.open-cluster-management.io"] + resources: + - "managedclusters" + - "managedclustersets" + - "managedclustersetbindings" + - "placements" + - "placementdecisions" + verbs: ["get", "list", "watch"] + - apiGroups: ["work.open-cluster-management.io"] + resources: + - "manifestworks" + verbs: ["get", "list", "watch"] + - apiGroups: ["addon.open-cluster-management.io"] + resources: + - "managedclusteraddons" + verbs: ["get", "list", "watch"] + # Authentication + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] + {{- with .Values.rbac.additionalRules }} + {{- toYaml . | nindent 2 }} + {{- end }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "ocm-dashboard.fullname" . }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} +subjects: + - kind: ServiceAccount + name: {{ include "ocm-dashboard.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +roleRef: + kind: ClusterRole + name: {{ include "ocm-dashboard.fullname" . }} + apiGroup: rbac.authorization.k8s.io +{{- end }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/templates/service.yaml b/dashboard/charts/ocm-dashboard/templates/service.yaml new file mode 100644 index 00000000..2cae1450 --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/service.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "ocm-dashboard.fullname" . }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + # UI Port (primary interface) + - port: 80 + targetPort: http + protocol: TCP + name: http + # API Port (for direct access if needed) + - port: 8080 + targetPort: api + protocol: TCP + name: api + selector: + {{- include "ocm-dashboard.selectorLabels" . | nindent 4 }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/templates/serviceaccount.yaml b/dashboard/charts/ocm-dashboard/templates/serviceaccount.yaml new file mode 100644 index 00000000..3a7c4c5b --- /dev/null +++ b/dashboard/charts/ocm-dashboard/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "ocm-dashboard.serviceAccountName" . }} + labels: + {{- include "ocm-dashboard.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: true +{{- end }} \ No newline at end of file diff --git a/dashboard/charts/ocm-dashboard/values.yaml b/dashboard/charts/ocm-dashboard/values.yaml new file mode 100644 index 00000000..d3795cb1 --- /dev/null +++ b/dashboard/charts/ocm-dashboard/values.yaml @@ -0,0 +1,193 @@ +# Default values for ocm-dashboard. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# API Service Configuration +api: + replicaCount: 2 + image: + registry: quay.io + repository: open-cluster-management/dashboard-api + pullPolicy: IfNotPresent + tag: "" + + service: + type: ClusterIP + port: 8080 + targetPort: 8080 + + resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 250m + memory: 256Mi + + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Environment variables for the API + env: + GIN_MODE: "release" + DASHBOARD_DEBUG: "false" + DASHBOARD_USE_MOCK: "false" + DASHBOARD_BYPASS_AUTH: "false" + PORT: "8080" + + # Additional environment variables + extraEnv: [] + + # Health checks + livenessProbe: + httpGet: + path: /health + port: api + scheme: HTTP + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 + + +# UI Service Configuration +ui: + replicaCount: 2 + image: + registry: quay.io + repository: open-cluster-management/dashboard-ui + pullPolicy: IfNotPresent + tag: "" + + service: + type: ClusterIP + port: 80 + targetPort: 3000 + + resources: + limits: + cpu: 200m + memory: 256Mi + requests: + cpu: 100m + memory: 128Mi + + autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 10 + targetCPUUtilizationPercentage: 80 + targetMemoryUtilizationPercentage: 80 + + # Health checks + livenessProbe: + httpGet: + path: /health + port: http + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# Service configuration (used by ingress template) +service: + port: 80 # Default to UI port + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + fsGroup: 65532 + seccompProfile: + type: RuntimeDefault + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + capabilities: + drop: + - ALL + +ingress: + enabled: true + className: "" + annotations: + # kubernetes.io/ingress.class: nginx + # cert-manager.io/cluster-issuer: letsencrypt-prod + nginx.ingress.kubernetes.io/rewrite-target: /$2 + hosts: + - host: ocm-dashboard.local + paths: + - path: /()(.*) + pathType: Prefix + backend: + service: + name: ocm-dashboard + port: + number: 80 + - path: /(api)(.*) + pathType: Prefix + backend: + service: + name: ocm-dashboard + port: + number: 8080 + tls: [] + # - secretName: ocm-dashboard-tls + # hosts: + # - ocm-dashboard.local + +tolerations: [] + +affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + labelSelector: + matchExpressions: + - key: app.kubernetes.io/name + operator: In + values: + - ocm-dashboard + topologyKey: kubernetes.io/hostname + +# RBAC configuration (only API needs cluster access) +rbac: + # Specifies whether RBAC resources should be created + create: true + # Additional rules to add to the ClusterRole + additionalRules: [] + +# Volume configuration for API and UI containers +volumes: + - name: tmp + emptyDir: + sizeLimit: 100Mi + +volumeMounts: + - name: tmp + mountPath: /tmp \ No newline at end of file diff --git a/dashboard/eslint.config.js b/dashboard/eslint.config.js new file mode 100644 index 00000000..092408a9 --- /dev/null +++ b/dashboard/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/dashboard/get-token.sh b/dashboard/get-token.sh new file mode 100755 index 00000000..fefbbb59 --- /dev/null +++ b/dashboard/get-token.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -e + +echo "🔧 OCM Dashboard Token Generator" +echo "=================================" +echo + +# Service account name and namespace +SA_NAME="dashboard-user" +NAMESPACE="default" + +echo "📋 Creating service account: $SA_NAME" +if kubectl get serviceaccount $SA_NAME -n $NAMESPACE &>/dev/null; then + echo "✅ Service account '$SA_NAME' already exists" +else + kubectl create serviceaccount $SA_NAME -n $NAMESPACE + echo "✅ Service account '$SA_NAME' created" +fi + +echo +echo "🔑 Creating cluster role binding..." +if kubectl get clusterrolebinding $SA_NAME &>/dev/null; then + echo "✅ Cluster role binding '$SA_NAME' already exists" +else + kubectl create clusterrolebinding $SA_NAME \ + --clusterrole=cluster-admin \ + --serviceaccount=$NAMESPACE:$SA_NAME + echo "✅ Cluster role binding '$SA_NAME' created" +fi + +echo +echo "🎫 Generating token (valid for 24 hours)..." +TOKEN=$(kubectl create token $SA_NAME --duration=24h --namespace=$NAMESPACE) + +echo +echo "🎉 SUCCESS! Your OCM Dashboard token:" +echo "======================================" +echo +echo $TOKEN +echo +echo "💡 Instructions:" +echo "1. Copy the token above" +echo "2. Go to OCM Dashboard login page" +echo "3. Paste the token in the 'Bearer Token' field" +echo "4. Click 'Sign In'" +echo +echo "⚠️ Note: This token is valid for 24 hours only." +echo " You'll need to generate a new one after expiration." +echo \ No newline at end of file diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 00000000..aba7e44a --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + OCM Dashboard + + +
+ + + diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 00000000..46018e0b --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,5268 @@ +{ + "name": "ocm-dashboard", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ocm-dashboard", + "version": "0.0.0", + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.1.0", + "@mui/material": "^7.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.3" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz", + "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helpers": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz", + "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", + "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz", + "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", + "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz", + "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.13.5", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", + "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.3.3", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/@emotion/cache": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", + "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", + "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT" + }, + "node_modules/@emotion/react": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", + "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/cache": "^11.14.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz", + "integrity": "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.2", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", + "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.13.5", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", + "@emotion/utils": "^1.4.2" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==", + "license": "MIT" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz", + "integrity": "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", + "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", + "license": "MIT" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", + "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", + "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.26.0.tgz", + "integrity": "sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.11.0.tgz", + "integrity": "sha512-k/1pb70eD638anoi0e8wUGAlbMJXyvdV4p62Ko+EZ7eBe1xMx8Uhak1R5DgfoofsK5IBBnRwsYGTaLZl+6/+RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.3", + "eventsource": "^3.0.2", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.0.tgz", + "integrity": "sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.0.tgz", + "integrity": "sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^7.1.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.0.tgz", + "integrity": "sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@mui/core-downloads-tracker": "^7.1.0", + "@mui/system": "^7.1.0", + "@mui/types": "^7.4.2", + "@mui/utils": "^7.1.0", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.12", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^19.1.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^7.1.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.0.tgz", + "integrity": "sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@mui/utils": "^7.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.0.tgz", + "integrity": "sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@emotion/cache": "^11.13.5", + "@emotion/serialize": "^1.3.3", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.0.tgz", + "integrity": "sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@mui/private-theming": "^7.1.0", + "@mui/styled-engine": "^7.1.0", + "@mui/types": "^7.4.2", + "@mui/utils": "^7.1.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.2.tgz", + "integrity": "sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.0.tgz", + "integrity": "sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.1", + "@mui/types": "^7.4.2", + "@types/prop-types": "^15.7.14", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^19.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.2.tgz", + "integrity": "sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.2.tgz", + "integrity": "sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.2.tgz", + "integrity": "sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.2.tgz", + "integrity": "sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.2.tgz", + "integrity": "sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.2.tgz", + "integrity": "sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.2.tgz", + "integrity": "sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.2.tgz", + "integrity": "sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.2.tgz", + "integrity": "sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.2.tgz", + "integrity": "sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.2.tgz", + "integrity": "sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.2.tgz", + "integrity": "sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.2.tgz", + "integrity": "sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.2.tgz", + "integrity": "sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.2.tgz", + "integrity": "sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.2.tgz", + "integrity": "sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.2.tgz", + "integrity": "sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.2.tgz", + "integrity": "sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.2.tgz", + "integrity": "sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.2.tgz", + "integrity": "sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.3.tgz", + "integrity": "sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==", + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.3.tgz", + "integrity": "sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.0.tgz", + "integrity": "sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/type-utils": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.32.0.tgz", + "integrity": "sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.32.0.tgz", + "integrity": "sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.32.0.tgz", + "integrity": "sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.32.0", + "@typescript-eslint/utils": "8.32.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.32.0.tgz", + "integrity": "sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.32.0.tgz", + "integrity": "sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/visitor-keys": "8.32.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.32.0.tgz", + "integrity": "sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.32.0", + "@typescript-eslint/types": "8.32.0", + "@typescript-eslint/typescript-estree": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.32.0.tgz", + "integrity": "sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.32.0", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.4.1.tgz", + "integrity": "sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.10", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001717", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001717.tgz", + "integrity": "sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "license": "MIT", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.151", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.151.tgz", + "integrity": "sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.26.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.26.0.tgz", + "integrity": "sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.20.0", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.13.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.26.0", + "@eslint/plugin-kit": "^0.2.8", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@modelcontextprotocol/sdk": "^1.8.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.3.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "zod": "^3.24.2" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", + "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.6.tgz", + "integrity": "sha512-l19WpE2m9hSuyP06+FbuUUf1G+R0SFLrtQfbRb9PRr+oimOfxQhgGCbVaXg5IvZyyTThJsxh6L/srkMiCeBPDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.1.tgz", + "integrity": "sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.0.tgz", + "integrity": "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": "^4.11 || 5 || ^5.0.0-beta.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.1.0.tgz", + "integrity": "sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", + "integrity": "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==", + "dev": true, + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.29.2", + "lightningcss-darwin-x64": "1.29.2", + "lightningcss-freebsd-x64": "1.29.2", + "lightningcss-linux-arm-gnueabihf": "1.29.2", + "lightningcss-linux-arm64-gnu": "1.29.2", + "lightningcss-linux-arm64-musl": "1.29.2", + "lightningcss-linux-x64-gnu": "1.29.2", + "lightningcss-linux-x64-musl": "1.29.2", + "lightningcss-win32-arm64-msvc": "1.29.2", + "lightningcss-win32-x64-msvc": "1.29.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.29.2.tgz", + "integrity": "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.29.2.tgz", + "integrity": "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.29.2.tgz", + "integrity": "sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.29.2.tgz", + "integrity": "sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.29.2.tgz", + "integrity": "sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.29.2.tgz", + "integrity": "sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.29.2.tgz", + "integrity": "sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.29.2.tgz", + "integrity": "sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.29.2.tgz", + "integrity": "sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.29.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.29.2.tgz", + "integrity": "sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", + "integrity": "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, + "node_modules/react-is": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", + "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.3.tgz", + "integrity": "sha512-3iUDM4/fZCQ89SXlDa+Ph3MevBrozBAI655OAfWQlTm9nBR0IKlrmNwFow5lPHttbwvITZfkeeeZFP6zt3F7pw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.5.3.tgz", + "integrity": "sha512-cK0jSaTyW4jV9SRKAItMIQfWZ/D6WEZafgHuuCb9g+SjhLolY78qc+De4w/Cz9ybjvLzShAmaIMEXt8iF1Cm+A==", + "license": "MIT", + "dependencies": { + "react-router": "7.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.40.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.2.tgz", + "integrity": "sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.40.2", + "@rollup/rollup-android-arm64": "4.40.2", + "@rollup/rollup-darwin-arm64": "4.40.2", + "@rollup/rollup-darwin-x64": "4.40.2", + "@rollup/rollup-freebsd-arm64": "4.40.2", + "@rollup/rollup-freebsd-x64": "4.40.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.2", + "@rollup/rollup-linux-arm-musleabihf": "4.40.2", + "@rollup/rollup-linux-arm64-gnu": "4.40.2", + "@rollup/rollup-linux-arm64-musl": "4.40.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.2", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-gnu": "4.40.2", + "@rollup/rollup-linux-riscv64-musl": "4.40.2", + "@rollup/rollup-linux-s390x-gnu": "4.40.2", + "@rollup/rollup-linux-x64-gnu": "4.40.2", + "@rollup/rollup-linux-x64-musl": "4.40.2", + "@rollup/rollup-win32-arm64-msvc": "4.40.2", + "@rollup/rollup-win32-ia32-msvc": "4.40.2", + "@rollup/rollup-win32-x64-msvc": "4.40.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.32.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.32.0.tgz", + "integrity": "sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.32.0", + "@typescript-eslint/parser": "8.32.0", + "@typescript-eslint/utils": "8.32.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.24.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.4.tgz", + "integrity": "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 00000000..f895dfab --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,34 @@ +{ + "name": "ocm-dashboard", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@mui/icons-material": "^7.1.0", + "@mui/material": "^7.1.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.5.3" + }, + "devDependencies": { + "@eslint/js": "^9.25.0", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "eslint": "^9.25.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^16.0.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.30.1", + "vite": "^6.3.5" + } +} diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml new file mode 100644 index 00000000..2cd98529 --- /dev/null +++ b/dashboard/pnpm-lock.yaml @@ -0,0 +1,3234 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@emotion/react': + specifier: ^11.14.0 + version: 11.14.0(@types/react@19.1.3)(react@19.1.0) + '@emotion/styled': + specifier: ^11.14.0 + version: 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@mui/icons-material': + specifier: ^7.1.0 + version: 7.1.0(@mui/material@7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@mui/material': + specifier: ^7.1.0 + version: 7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: + specifier: ^19.1.0 + version: 19.1.0 + react-dom: + specifier: ^19.1.0 + version: 19.1.0(react@19.1.0) + react-router-dom: + specifier: ^7.5.3 + version: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + devDependencies: + '@eslint/js': + specifier: ^9.25.0 + version: 9.26.0 + '@types/react': + specifier: ^19.1.2 + version: 19.1.3 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.1.3(@types/react@19.1.3) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.4.1(vite@6.3.5) + eslint: + specifier: ^9.25.0 + version: 9.26.0 + eslint-plugin-react-hooks: + specifier: ^5.2.0 + version: 5.2.0(eslint@9.26.0) + eslint-plugin-react-refresh: + specifier: ^0.4.19 + version: 0.4.20(eslint@9.26.0) + globals: + specifier: ^16.0.0 + version: 16.1.0 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: ^8.30.1 + version: 8.32.0(eslint@9.26.0)(typescript@5.8.3) + vite: + specifier: ^6.3.5 + version: 6.3.5 + +packages: + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.27.2': + resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.27.1': + resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.27.1': + resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.27.1': + resolution: {integrity: sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + 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.27.1': + resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.27.1': + resolution: {integrity: sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.2': + resolution: {integrity: sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.27.1': + resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.27.1': + resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.27.1': + resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} + engines: {node: '>=6.9.0'} + + '@emotion/babel-plugin@11.13.5': + resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} + + '@emotion/cache@11.14.0': + resolution: {integrity: sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==} + + '@emotion/hash@0.9.2': + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + + '@emotion/is-prop-valid@1.3.1': + resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + + '@emotion/memoize@0.9.0': + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + + '@emotion/react@11.14.0': + resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/serialize@1.3.3': + resolution: {integrity: sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA==} + + '@emotion/sheet@1.4.0': + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + + '@emotion/styled@11.14.0': + resolution: {integrity: sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + + '@emotion/unitless@0.10.0': + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0': + resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} + peerDependencies: + react: '>=16.8.0' + + '@emotion/utils@1.4.2': + resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} + + '@emotion/weak-memoize@0.4.0': + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + + '@esbuild/aix-ppc64@0.25.4': + resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.7.0': + resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} + 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.1': + resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.20.0': + resolution: {integrity: sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.2.2': + resolution: {integrity: sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.13.0': + resolution: {integrity: sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.1': + resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.26.0': + resolution: {integrity: sha512-I9XlJawFdSMvWjDt6wksMCrgns5ggLNfFwFvnShsleWruvXM514Qxk8V246efTw+eo9JABvVz+u3q2RiAowKxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.6': + resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.2.8': + resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@modelcontextprotocol/sdk@1.11.1': + resolution: {integrity: sha512-9LfmxKTb1v+vUS1/emSk1f5ePmTLkb9Le9AxOB5T0XM59EUumwcS45z05h7aiZx3GI0Bl7mjb3FMEglYj+acuQ==} + engines: {node: '>=18'} + + '@mui/core-downloads-tracker@7.1.0': + resolution: {integrity: sha512-E0OqhZv548Qdc0PwWhLVA2zmjJZSTvaL4ZhoswmI8NJEC1tpW2js6LLP827jrW9MEiXYdz3QS6+hask83w74yQ==} + + '@mui/icons-material@7.1.0': + resolution: {integrity: sha512-1mUPMAZ+Qk3jfgL5ftRR06ATH/Esi0izHl1z56H+df6cwIlCWG66RXciUqeJCttbOXOQ5y2DCjLZI/4t3Yg3LA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@mui/material': ^7.1.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/material@7.1.0': + resolution: {integrity: sha512-ahUJdrhEv+mCp4XHW+tHIEYzZMSRLg8z4AjUOsj44QpD1ZaMxQoVOG2xiHvLFdcsIPbgSRx1bg1eQSheHBgvtg==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@mui/material-pigment-css': ^7.1.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@mui/material-pigment-css': + optional: true + '@types/react': + optional: true + + '@mui/private-theming@7.1.0': + resolution: {integrity: sha512-4Kck4jxhqF6YxNwJdSae1WgDfXVg0lIH6JVJ7gtuFfuKcQCgomJxPvUEOySTFRPz1IZzwz5OAcToskRdffElDA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/styled-engine@7.1.0': + resolution: {integrity: sha512-m0mJ0c6iRC+f9hMeRe0W7zZX1wme3oUX0+XTVHjPG7DJz6OdQ6K/ggEOq7ZdwilcpdsDUwwMfOmvO71qDkYd2w==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.4.1 + '@emotion/styled': ^11.3.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + + '@mui/system@7.1.0': + resolution: {integrity: sha512-iedAWgRJMCxeMHvkEhsDlbvkK+qKf9me6ofsf7twk/jfT4P1ImVf7Rwb5VubEA0sikrVL+1SkoZM41M4+LNAVA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@emotion/react': ^11.5.0 + '@emotion/styled': ^11.3.0 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/react': + optional: true + '@emotion/styled': + optional: true + '@types/react': + optional: true + + '@mui/types@7.4.2': + resolution: {integrity: sha512-edRc5JcLPsrlNFYyTPxds+d5oUovuUxnnDtpJUbP6WMeV4+6eaX/mqai1ZIWT62lCOe0nlrON0s9HDiv5en5bA==} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@mui/utils@7.1.0': + resolution: {integrity: sha512-/OM3S8kSHHmWNOP+NH9xEtpYSG10upXeQ0wLZnfDgmgadTAk5F4MQfFLyZ5FCRJENB3eRzltMmaNl6UtDnPovw==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + '@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'} + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@rollup/rollup-android-arm-eabi@4.40.2': + resolution: {integrity: sha512-JkdNEq+DFxZfUwxvB58tHMHBHVgX23ew41g1OQinthJ+ryhdRk67O31S7sYw8u2lTjHUPFxwar07BBt1KHp/hg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.40.2': + resolution: {integrity: sha512-13unNoZ8NzUmnndhPTkWPWbX3vtHodYmy+I9kuLxN+F+l+x3LdVF7UCu8TWVMt1POHLh6oDHhnOA04n8oJZhBw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.40.2': + resolution: {integrity: sha512-Gzf1Hn2Aoe8VZzevHostPX23U7N5+4D36WJNHK88NZHCJr7aVMG4fadqkIf72eqVPGjGc0HJHNuUaUcxiR+N/w==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.40.2': + resolution: {integrity: sha512-47N4hxa01a4x6XnJoskMKTS8XZ0CZMd8YTbINbi+w03A2w4j1RTlnGHOz/P0+Bg1LaVL6ufZyNprSg+fW5nYQQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.40.2': + resolution: {integrity: sha512-8t6aL4MD+rXSHHZUR1z19+9OFJ2rl1wGKvckN47XFRVO+QL/dUSpKA2SLRo4vMg7ELA8pzGpC+W9OEd1Z/ZqoQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.40.2': + resolution: {integrity: sha512-C+AyHBzfpsOEYRFjztcYUFsH4S7UsE9cDtHCtma5BK8+ydOZYgMmWg1d/4KBytQspJCld8ZIujFMAdKG1xyr4Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': + resolution: {integrity: sha512-de6TFZYIvJwRNjmW3+gaXiZ2DaWL5D5yGmSYzkdzjBDS3W+B9JQ48oZEsmMvemqjtAFzE16DIBLqd6IQQRuG9Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.40.2': + resolution: {integrity: sha512-urjaEZubdIkacKc930hUDOfQPysezKla/O9qV+O89enqsqUmQm8Xj8O/vh0gHg4LYfv7Y7UsE3QjzLQzDYN1qg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.40.2': + resolution: {integrity: sha512-KlE8IC0HFOC33taNt1zR8qNlBYHj31qGT1UqWqtvR/+NuCVhfufAq9fxO8BMFC22Wu0rxOwGVWxtCMvZVLmhQg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.40.2': + resolution: {integrity: sha512-j8CgxvfM0kbnhu4XgjnCWJQyyBOeBI1Zq91Z850aUddUmPeQvuAy6OiMdPS46gNFgy8gN1xkYyLgwLYZG3rBOg==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': + resolution: {integrity: sha512-Ybc/1qUampKuRF4tQXc7G7QY9YRyeVSykfK36Y5Qc5dmrIxwFhrOzqaVTNoZygqZ1ZieSWTibfFhQ5qK8jpWxw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': + resolution: {integrity: sha512-3FCIrnrt03CCsZqSYAOW/k9n625pjpuMzVfeI+ZBUSDT3MVIFDSPfSUgIl9FqUftxcUXInvFah79hE1c9abD+Q==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.40.2': + resolution: {integrity: sha512-QNU7BFHEvHMp2ESSY3SozIkBPaPBDTsfVNGx3Xhv+TdvWXFGOSH2NJvhD1zKAT6AyuuErJgbdvaJhYVhVqrWTg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.40.2': + resolution: {integrity: sha512-5W6vNYkhgfh7URiXTO1E9a0cy4fSgfE4+Hl5agb/U1sa0kjOLMLC1wObxwKxecE17j0URxuTrYZZME4/VH57Hg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.40.2': + resolution: {integrity: sha512-B7LKIz+0+p348JoAL4X/YxGx9zOx3sR+o6Hj15Y3aaApNfAshK8+mWZEf759DXfRLeL2vg5LYJBB7DdcleYCoQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.40.2': + resolution: {integrity: sha512-lG7Xa+BmBNwpjmVUbmyKxdQJ3Q6whHjMjzQplOs5Z+Gj7mxPtWakGHqzMqNER68G67kmCX9qX57aRsW5V0VOng==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.40.2': + resolution: {integrity: sha512-tD46wKHd+KJvsmije4bUskNuvWKFcTOIM9tZ/RrmIvcXnbi0YK/cKS9FzFtAm7Oxi2EhV5N2OpfFB348vSQRXA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.40.2': + resolution: {integrity: sha512-Bjv/HG8RRWLNkXwQQemdsWw4Mg+IJ29LK+bJPW2SCzPKOUaMmPEppQlu/Fqk1d7+DX3V7JbFdbkh/NMmurT6Pg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.40.2': + resolution: {integrity: sha512-dt1llVSGEsGKvzeIO76HToiYPNPYPkmjhMHhP00T9S4rDern8P2ZWvWAQUEJ+R1UdMWJ/42i/QqJ2WV765GZcA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.40.2': + resolution: {integrity: sha512-bwspbWB04XJpeElvsp+DCylKfF4trJDa2Y9Go8O6A7YLX2LIKGcNK/CYImJN6ZP4DcuOHB4Utl3iCbnR62DudA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.7': + resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==} + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/parse-json@4.0.2': + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + + '@types/prop-types@15.7.14': + resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} + + '@types/react-dom@19.1.3': + resolution: {integrity: sha512-rJXC08OG0h3W6wDMFxQrZF00Kq6qQvw0djHRdzl3U5DnIERz0MRce3WVc7IS6JYBwtaP/DwYtRRjVlvivNveKg==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react-transition-group@4.4.12': + resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} + peerDependencies: + '@types/react': '*' + + '@types/react@19.1.3': + resolution: {integrity: sha512-dLWQ+Z0CkIvK1J8+wrDPwGxEYFA4RAyHoZPxHVGspYmFVnwGSNT24cGIhFJrtfRnWVuW8X7NO52gCXmhkVUWGQ==} + + '@typescript-eslint/eslint-plugin@8.32.0': + resolution: {integrity: sha512-/jU9ettcntkBFmWUzzGgsClEi2ZFiikMX5eEQsmxIAWMOn4H3D4rvHssstmAHGVvrYnaMqdWWWg0b5M6IN/MTQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/parser@8.32.0': + resolution: {integrity: sha512-B2MdzyWxCE2+SqiZHAjPphft+/2x2FlO9YBx7eKE1BCb+rqBlQdhtAEhzIEdozHd55DXPmxBdpMygFJjfjjA9A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/scope-manager@8.32.0': + resolution: {integrity: sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/type-utils@8.32.0': + resolution: {integrity: sha512-t2vouuYQKEKSLtJaa5bB4jHeha2HJczQ6E5IXPDPgIty9EqcJxpr1QHQ86YyIPwDwxvUmLfP2YADQ5ZY4qddZg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/types@8.32.0': + resolution: {integrity: sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.32.0': + resolution: {integrity: sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/utils@8.32.0': + resolution: {integrity: sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/visitor-keys@8.32.0': + resolution: {integrity: sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.4.1': + resolution: {integrity: sha512-IpEm5ZmeXAP/osiBXVVP5KjFMzbWOonMs0NaQQl+xYnUAcq4oHUBsF2+p4MgKWG4YMmFYJU8A6sxRPuowllm6w==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + 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.14.1: + resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + + brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.24.5: + resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + 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-lite@1.0.30001717: + resolution: {integrity: sha512-auPpttCq6BDEG8ZAuHJIplGw6GODhjw+/11e7IjpnYCxZcW/ONgPs0KVBJ0d1bY3e2+7PRe5RCLyP+PfwVgkYw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + 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==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + 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'} + + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.151: + resolution: {integrity: sha512-Rl6uugut2l9sLojjS4H4SAr3A4IgACMLgpuEMPYCVcKydzfyPrn5absNRju38IhQOf/NwjJY8OGWjlteqYeBCA==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + + 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-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + 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@5.2.0: + resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + + eslint-plugin-react-refresh@0.4.20: + resolution: {integrity: sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.3.0: + resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.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-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint@9.26.0: + resolution: {integrity: sha512-Hx0MOjPh6uK9oq9nVsATZKE/Wlbai7KFjfCuw9UHaguDW3x+HF0O5nIi3ud39TWgrTjTO5nHxmL3R1eANinWHQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.3.0: + resolution: {integrity: sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + 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'} + + 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'} + + eventsource-parser@3.0.1: + resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@7.5.0: + resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} + engines: {node: '>= 16'} + peerDependencies: + express: ^4.11 || 5 || ^5.0.0-beta.1 + + express@5.1.0: + resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} + engines: {node: '>= 18'} + + 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.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.4.4: + resolution: {integrity: sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.0: + resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} + engines: {node: '>= 0.8'} + + find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + 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==} + + 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-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + 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'} + + globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.1.0: + resolution: {integrity: sha512-aibexHNbb/jiUSObBgpHLj+sIuUmJnYcgXBlrfsiDZ9rt4aF2TFRbyLgZ2iFQuVZ1K5Mx3FVkbKRSgKrbK3K2g==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + 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 + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + 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@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + 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'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + 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'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + 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-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-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + 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.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + 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@3.0.0: + resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} + engines: {node: '>= 0.8'} + + react-dom@19.1.0: + resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} + peerDependencies: + react: ^19.1.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@19.1.0: + resolution: {integrity: sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==} + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.6.0: + resolution: {integrity: sha512-DYgm6RDEuKdopSyGOWZGtDfSm7Aofb8CCzgkliTjtu/eDuB0gcsv6qdFhhi8HdtmA+KHkt5MfZ5K2PdzjugYsA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.6.0: + resolution: {integrity: sha512-GGufuHIVCJDbnIAXP3P9Sxzq3UUsddG3rrI3ut1q6m0FI6vxVBF3JoPQ38+W/blslLH4a5Yutp8drkEpXoddGQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + + react@19.1.0: + resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.40.2: + resolution: {integrity: sha512-tfUOg6DTP4rhQ3VjOO6B4wyrJnGOX85requAXvqYTHsOgb2TFJdZ3aWpT8W2kPoypSGP7dZUyzxJ9ee4buM5Fg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.1: + resolution: {integrity: sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.0: + resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} + engines: {node: '>= 18'} + + serve-static@2.2.0: + resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + + 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'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + + 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'} + + tinyglobby@0.2.13: + resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} + engines: {node: '>=12.0.0'} + + 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'} + + ts-api-utils@2.1.0: + resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript-eslint@8.32.0: + resolution: {integrity: sha512-UMq2kxdXCzinFFPsXc9o2ozIpYCCOiEC46MG3yEh5Vipq6BO27otTtEBZA1fQ66DulEUgE97ucQ/3YY66CPg0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@6.3.5: + resolution: {integrity: sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.24.5: + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + peerDependencies: + zod: ^3.24.1 + + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + +snapshots: + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.27.1 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.27.2': {} + + '@babel/core@7.27.1': + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.1 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.27.1(@babel/core@7.27.1) + '@babel/helpers': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/template': 7.27.2 + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 + convert-source-map: 2.0.0 + debug: 4.4.0 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.27.1': + dependencies: + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.27.2 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.24.5 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.27.1 + '@babel/types': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + '@babel/traverse': 7.27.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.27.1': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.27.1': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.27.1 + + '@babel/parser@7.27.2': + dependencies: + '@babel/types': 7.27.1 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.27.1)': + dependencies: + '@babel/core': 7.27.1 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/runtime@7.27.1': {} + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + + '@babel/traverse@7.27.1': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.1 + '@babel/parser': 7.27.2 + '@babel/template': 7.27.2 + '@babel/types': 7.27.1 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.27.1': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.27.1 + + '@emotion/babel-plugin@11.13.5': + dependencies: + '@babel/helper-module-imports': 7.27.1 + '@babel/runtime': 7.27.1 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + + '@emotion/cache@11.14.0': + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + + '@emotion/hash@0.9.2': {} + + '@emotion/is-prop-valid@1.3.1': + dependencies: + '@emotion/memoize': 0.9.0 + + '@emotion/memoize@0.9.0': {} + + '@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@emotion/babel-plugin': 11.13.5 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.0) + '@emotion/utils': 1.4.2 + '@emotion/weak-memoize': 0.4.0 + hoist-non-react-statics: 3.3.2 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + transitivePeerDependencies: + - supports-color + + '@emotion/serialize@1.3.3': + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.2 + csstype: 3.1.3 + + '@emotion/sheet@1.4.0': {} + + '@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@emotion/babel-plugin': 11.13.5 + '@emotion/is-prop-valid': 1.3.1 + '@emotion/react': 11.14.0(@types/react@19.1.3)(react@19.1.0) + '@emotion/serialize': 1.3.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.2.0(react@19.1.0) + '@emotion/utils': 1.4.2 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + transitivePeerDependencies: + - supports-color + + '@emotion/unitless@0.10.0': {} + + '@emotion/use-insertion-effect-with-fallbacks@1.2.0(react@19.1.0)': + dependencies: + react: 19.1.0 + + '@emotion/utils@1.4.2': {} + + '@emotion/weak-memoize@0.4.0': {} + + '@esbuild/aix-ppc64@0.25.4': + optional: true + + '@esbuild/android-arm64@0.25.4': + optional: true + + '@esbuild/android-arm@0.25.4': + optional: true + + '@esbuild/android-x64@0.25.4': + optional: true + + '@esbuild/darwin-arm64@0.25.4': + optional: true + + '@esbuild/darwin-x64@0.25.4': + optional: true + + '@esbuild/freebsd-arm64@0.25.4': + optional: true + + '@esbuild/freebsd-x64@0.25.4': + optional: true + + '@esbuild/linux-arm64@0.25.4': + optional: true + + '@esbuild/linux-arm@0.25.4': + optional: true + + '@esbuild/linux-ia32@0.25.4': + optional: true + + '@esbuild/linux-loong64@0.25.4': + optional: true + + '@esbuild/linux-mips64el@0.25.4': + optional: true + + '@esbuild/linux-ppc64@0.25.4': + optional: true + + '@esbuild/linux-riscv64@0.25.4': + optional: true + + '@esbuild/linux-s390x@0.25.4': + optional: true + + '@esbuild/linux-x64@0.25.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.4': + optional: true + + '@esbuild/netbsd-x64@0.25.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.4': + optional: true + + '@esbuild/openbsd-x64@0.25.4': + optional: true + + '@esbuild/sunos-x64@0.25.4': + optional: true + + '@esbuild/win32-arm64@0.25.4': + optional: true + + '@esbuild/win32-ia32@0.25.4': + optional: true + + '@esbuild/win32-x64@0.25.4': + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.26.0)': + dependencies: + eslint: 9.26.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint/config-array@0.20.0': + dependencies: + '@eslint/object-schema': 2.1.6 + debug: 4.4.0 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.2.2': {} + + '@eslint/core@0.13.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.1': + dependencies: + ajv: 6.12.6 + debug: 4.4.0 + espree: 10.3.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.26.0': {} + + '@eslint/object-schema@2.1.6': {} + + '@eslint/plugin-kit@0.2.8': + dependencies: + '@eslint/core': 0.13.0 + levn: 0.4.1 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@modelcontextprotocol/sdk@1.11.1': + dependencies: + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + express: 5.1.0 + express-rate-limit: 7.5.0(express@5.1.0) + pkce-challenge: 5.0.0 + raw-body: 3.0.0 + zod: 3.24.4 + zod-to-json-schema: 3.24.5(zod@3.24.4) + transitivePeerDependencies: + - supports-color + + '@mui/core-downloads-tracker@7.1.0': {} + + '@mui/icons-material@7.1.0(@mui/material@7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/material': 7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + + '@mui/material@7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/core-downloads-tracker': 7.1.0 + '@mui/system': 7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@mui/types': 7.4.2(@types/react@19.1.3) + '@mui/utils': 7.1.0(@types/react@19.1.3)(react@19.1.0) + '@popperjs/core': 2.11.8 + '@types/react-transition-group': 4.4.12(@types/react@19.1.3) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-is: 19.1.0 + react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.3)(react@19.1.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@types/react': 19.1.3 + + '@mui/private-theming@7.1.0(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/utils': 7.1.0(@types/react@19.1.3)(react@19.1.0) + prop-types: 15.8.1 + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + + '@mui/styled-engine@7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@emotion/cache': 11.14.0 + '@emotion/serialize': 1.3.3 + '@emotion/sheet': 1.4.0 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.0 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.3)(react@19.1.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + + '@mui/system@7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/private-theming': 7.1.0(@types/react@19.1.3)(react@19.1.0) + '@mui/styled-engine': 7.1.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0))(react@19.1.0) + '@mui/types': 7.4.2(@types/react@19.1.3) + '@mui/utils': 7.1.0(@types/react@19.1.3)(react@19.1.0) + clsx: 2.1.1 + csstype: 3.1.3 + prop-types: 15.8.1 + react: 19.1.0 + optionalDependencies: + '@emotion/react': 11.14.0(@types/react@19.1.3)(react@19.1.0) + '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@19.1.3)(react@19.1.0))(@types/react@19.1.3)(react@19.1.0) + '@types/react': 19.1.3 + + '@mui/types@7.4.2(@types/react@19.1.3)': + dependencies: + '@babel/runtime': 7.27.1 + optionalDependencies: + '@types/react': 19.1.3 + + '@mui/utils@7.1.0(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@babel/runtime': 7.27.1 + '@mui/types': 7.4.2(@types/react@19.1.3) + '@types/prop-types': 15.7.14 + clsx: 2.1.1 + prop-types: 15.8.1 + react: 19.1.0 + react-is: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + + '@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.19.1 + + '@popperjs/core@2.11.8': {} + + '@rollup/rollup-android-arm-eabi@4.40.2': + optional: true + + '@rollup/rollup-android-arm64@4.40.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.40.2': + optional: true + + '@rollup/rollup-darwin-x64@4.40.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.40.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.40.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.40.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.40.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.40.2': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.40.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.40.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.40.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.40.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.40.2': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.7 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.27.1 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.27.2 + '@babel/types': 7.27.1 + + '@types/babel__traverse@7.20.7': + dependencies: + '@babel/types': 7.27.1 + + '@types/estree@1.0.7': {} + + '@types/json-schema@7.0.15': {} + + '@types/parse-json@4.0.2': {} + + '@types/prop-types@15.7.14': {} + + '@types/react-dom@19.1.3(@types/react@19.1.3)': + dependencies: + '@types/react': 19.1.3 + + '@types/react-transition-group@4.4.12(@types/react@19.1.3)': + dependencies: + '@types/react': 19.1.3 + + '@types/react@19.1.3': + dependencies: + csstype: 3.1.3 + + '@typescript-eslint/eslint-plugin@8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.32.0 + '@typescript-eslint/type-utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.32.0 + eslint: 9.26.0 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.32.0 + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.32.0 + debug: 4.4.0 + eslint: 9.26.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.32.0': + dependencies: + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/visitor-keys': 8.32.0 + + '@typescript-eslint/type-utils@8.32.0(eslint@9.26.0)(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + debug: 4.4.0 + eslint: 9.26.0 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.32.0': {} + + '@typescript-eslint/typescript-estree@8.32.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/visitor-keys': 8.32.0 + debug: 4.4.0 + fast-glob: 3.3.3 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.7.1 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.32.0(eslint@9.26.0)(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0) + '@typescript-eslint/scope-manager': 8.32.0 + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3) + eslint: 9.26.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.32.0': + dependencies: + '@typescript-eslint/types': 8.32.0 + eslint-visitor-keys: 4.2.0 + + '@vitejs/plugin-react@4.4.1(vite@6.3.5)': + dependencies: + '@babel/core': 7.27.1 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.27.1) + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.3.5 + transitivePeerDependencies: + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.1 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.14.1): + dependencies: + acorn: 8.14.1 + + acorn@8.14.1: {} + + ajv@6.12.6: + 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-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + babel-plugin-macros@3.1.0: + dependencies: + '@babel/runtime': 7.27.1 + cosmiconfig: 7.1.0 + resolve: 1.22.10 + + balanced-match@1.0.2: {} + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.0 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.0 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.11: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.1: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.24.5: + dependencies: + caniuse-lite: 1.0.30001717 + electron-to-chromium: 1.5.151 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.5) + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001717: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@1.9.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.0.2: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@7.1.0: + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.1 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.1.3: {} + + debug@4.4.0: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + depd@2.0.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.27.1 + csstype: 3.1.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.151: {} + + encodeurl@2.0.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@5.2.0(eslint@9.26.0): + dependencies: + eslint: 9.26.0 + + eslint-plugin-react-refresh@0.4.20(eslint@9.26.0): + dependencies: + eslint: 9.26.0 + + eslint-scope@8.3.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint@9.26.0: + dependencies: + '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0) + '@eslint-community/regexpp': 4.12.1 + '@eslint/config-array': 0.20.0 + '@eslint/config-helpers': 0.2.2 + '@eslint/core': 0.13.0 + '@eslint/eslintrc': 3.3.1 + '@eslint/js': 9.26.0 + '@eslint/plugin-kit': 0.2.8 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@modelcontextprotocol/sdk': 1.11.1 + '@types/estree': 1.0.7 + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.0 + escape-string-regexp: 4.0.0 + eslint-scope: 8.3.0 + eslint-visitor-keys: 4.2.0 + espree: 10.3.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + zod: 3.24.4 + transitivePeerDependencies: + - supports-color + + espree@10.3.0: + dependencies: + acorn: 8.14.1 + acorn-jsx: 5.3.2(acorn@8.14.1) + eslint-visitor-keys: 4.2.0 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.1 + + express-rate-limit@7.5.0(express@5.1.0): + dependencies: + express: 5.1.0 + + express@5.1.0: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.0 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.0 + fresh: 2.0.0 + http-errors: 2.0.0 + merge-descriptors: 2.0.0 + mime-types: 3.0.1 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.0 + serve-static: 2.2.0 + statuses: 2.0.1 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + 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.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.4.4(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.0: + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + find-root@1.1.0: {} + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + 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-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@11.12.0: {} + + globals@14.0.0: {} + + globals@16.1.0: {} + + gopd@1.2.0: {} + + graphemer@1.4.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.11 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.1 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-releases@2.0.19: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + 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 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-to-regexp@8.2.0: {} + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.2: {} + + pkce-challenge@5.0.0: {} + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + range-parser@1.2.1: {} + + raw-body@3.0.0: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + unpipe: 1.0.0 + + react-dom@19.1.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.26.0 + + react-is@16.13.1: {} + + react-is@19.1.0: {} + + react-refresh@0.17.0: {} + + react-router-dom@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + react-router: 7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + + react-router@7.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + cookie: 1.0.2 + react: 19.1.0 + set-cookie-parser: 2.7.1 + optionalDependencies: + react-dom: 19.1.0(react@19.1.0) + + react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@babel/runtime': 7.27.1 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + + react@19.1.0: {} + + resolve-from@4.0.0: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.40.2: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.2 + '@rollup/rollup-android-arm64': 4.40.2 + '@rollup/rollup-darwin-arm64': 4.40.2 + '@rollup/rollup-darwin-x64': 4.40.2 + '@rollup/rollup-freebsd-arm64': 4.40.2 + '@rollup/rollup-freebsd-x64': 4.40.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.2 + '@rollup/rollup-linux-arm-musleabihf': 4.40.2 + '@rollup/rollup-linux-arm64-gnu': 4.40.2 + '@rollup/rollup-linux-arm64-musl': 4.40.2 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.2 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.2 + '@rollup/rollup-linux-riscv64-gnu': 4.40.2 + '@rollup/rollup-linux-riscv64-musl': 4.40.2 + '@rollup/rollup-linux-s390x-gnu': 4.40.2 + '@rollup/rollup-linux-x64-gnu': 4.40.2 + '@rollup/rollup-linux-x64-musl': 4.40.2 + '@rollup/rollup-win32-arm64-msvc': 4.40.2 + '@rollup/rollup-win32-ia32-msvc': 4.40.2 + '@rollup/rollup-win32-x64-msvc': 4.40.2 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.0 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + scheduler@0.26.0: {} + + semver@6.3.1: {} + + semver@7.7.1: {} + + send@1.2.0: + dependencies: + debug: 4.4.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.0 + mime-types: 3.0.1 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.0: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.0 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.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 + + source-map-js@1.2.1: {} + + source-map@0.5.7: {} + + statuses@2.0.1: {} + + strip-json-comments@3.1.1: {} + + stylis@4.2.0: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tinyglobby@0.2.13: + dependencies: + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + ts-api-utils@2.1.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + typescript-eslint@8.32.0(eslint@9.26.0)(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.32.0(@typescript-eslint/parser@8.32.0(eslint@9.26.0)(typescript@5.8.3))(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3) + eslint: 9.26.0 + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.8.3: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.5): + dependencies: + browserslist: 4.24.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vary@1.1.2: {} + + vite@6.3.5: + dependencies: + esbuild: 0.25.4 + fdir: 6.4.4(picomatch@4.0.2) + picomatch: 4.0.2 + postcss: 8.5.3 + rollup: 4.40.2 + tinyglobby: 0.2.13 + optionalDependencies: + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yallist@3.1.1: {} + + yaml@1.10.2: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.24.5(zod@3.24.4): + dependencies: + zod: 3.24.4 + + zod@3.24.4: {} diff --git a/dashboard/public/favicons/android-chrome-192x192.png b/dashboard/public/favicons/android-chrome-192x192.png new file mode 100644 index 00000000..dfcc1b95 Binary files /dev/null and b/dashboard/public/favicons/android-chrome-192x192.png differ diff --git a/dashboard/public/favicons/android-chrome-512x512.png b/dashboard/public/favicons/android-chrome-512x512.png new file mode 100644 index 00000000..a819366d Binary files /dev/null and b/dashboard/public/favicons/android-chrome-512x512.png differ diff --git a/dashboard/public/favicons/apple-touch-icon.png b/dashboard/public/favicons/apple-touch-icon.png new file mode 100644 index 00000000..911546c1 Binary files /dev/null and b/dashboard/public/favicons/apple-touch-icon.png differ diff --git a/dashboard/public/favicons/favicon-16x16.png b/dashboard/public/favicons/favicon-16x16.png new file mode 100644 index 00000000..70a265bf Binary files /dev/null and b/dashboard/public/favicons/favicon-16x16.png differ diff --git a/dashboard/public/favicons/favicon-32x32.png b/dashboard/public/favicons/favicon-32x32.png new file mode 100644 index 00000000..50384def Binary files /dev/null and b/dashboard/public/favicons/favicon-32x32.png differ diff --git a/dashboard/public/favicons/favicon.ico b/dashboard/public/favicons/favicon.ico new file mode 100644 index 00000000..4e83208b Binary files /dev/null and b/dashboard/public/favicons/favicon.ico differ diff --git a/dashboard/public/images/demo.gif b/dashboard/public/images/demo.gif new file mode 100644 index 00000000..3c851bec Binary files /dev/null and b/dashboard/public/images/demo.gif differ diff --git a/dashboard/public/manifest.json b/dashboard/public/manifest.json new file mode 100644 index 00000000..df336985 --- /dev/null +++ b/dashboard/public/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "OCM Dashboard", + "short_name": "OCM Dashboard", + "icons": [ + { + "src": "/favicons/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/favicons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone", + "start_url": "/" +} \ No newline at end of file diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx new file mode 100644 index 00000000..a5f3fb2a --- /dev/null +++ b/dashboard/src/App.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AuthProvider, useAuth } from './auth/AuthContext'; +import ClusterDetailPage from './components/ClusterDetailPage'; +import Login from './components/Login'; +import AppShell from './components/layout/AppShell'; +import OverviewPage from './components/OverviewPage'; +import ClusterListPage from './components/ClusterListPage'; +import ClustersetList from './components/ClustersetList'; +import PlacementListPage from './components/PlacementListPage'; +import { MuiThemeProvider } from './theme/ThemeProvider'; + +// Protected route component that redirects to login if not authenticated +const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { + const { isAuthenticated } = useAuth(); + + console.log('ProtectedRoute: isAuthenticated =', isAuthenticated); + + if (!isAuthenticated) { + console.log('Redirecting to login...'); + return ; + } + + return children; +}; + +function AppContent() { + console.log('AppContent rendering...'); + + return ( + + + } /> + + {/* Use AppShell as the parent layout for all protected routes */} + + + + } + > + {/* Child routes will be rendered at in AppShell */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} + +function App() { + console.log('App component rendering...'); + + useEffect(() => { + console.log('App mounted, DEV =', import.meta.env.DEV); + // Force log in for development + if (import.meta.env.DEV) { + localStorage.setItem('authToken', 'dev-mock-token'); + console.log('Dev token set in localStorage'); + } + }, []); + + return ( + + + + + + ); +} + +export default App; diff --git a/dashboard/src/api/addonService.ts b/dashboard/src/api/addonService.ts new file mode 100644 index 00000000..c99da017 --- /dev/null +++ b/dashboard/src/api/addonService.ts @@ -0,0 +1,179 @@ +import { createHeaders } from './utils'; + +export interface ManagedClusterAddon { + id: string; + name: string; + namespace: string; + installNamespace: string; + creationTimestamp?: string; + conditions?: { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }[]; + registrations?: { + signerName: string; + subject: { + groups: string[]; + user: string; + }; + }[]; + supportedConfigs?: { + group: string; + resource: string; + }[]; +} + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Fetch all addons for a specific cluster +export const fetchClusterAddons = async (clusterName: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: `${clusterName}-managed-serviceaccount`, + name: 'managed-serviceaccount', + namespace: clusterName, + installNamespace: 'open-cluster-management-agent-addon', + creationTimestamp: "2025-05-20T08:52:35Z", + conditions: [ + { + type: "Progressing", + status: "False", + reason: "Completed", + message: "completed with no errors.", + lastTransitionTime: "2025-05-20T08:52:35Z" + }, + { + type: "Configured", + status: "True", + reason: "ConfigurationsConfigured", + message: "Configurations configured", + lastTransitionTime: "2025-05-20T08:52:35Z" + }, + { + type: "Available", + status: "True", + reason: "ManagedClusterAddOnLeaseUpdated", + message: "managed-serviceaccount add-on is available.", + lastTransitionTime: "2025-05-20T08:53:15Z" + }, + { + type: "RegistrationApplied", + status: "True", + reason: "SetPermissionApplied", + message: "Registration of the addon agent is configured", + lastTransitionTime: "2025-05-20T08:52:59Z" + }, + { + type: "ClusterCertificateRotated", + status: "True", + reason: "ClientCertificateUpdated", + message: "client certificate rotated starting from 2025-05-20 08:47:59 +0000 UTC to 2026-05-20 08:47:59 +0000 UTC", + lastTransitionTime: "2025-05-20T08:52:59Z" + }, + { + type: "ManifestApplied", + status: "True", + reason: "AddonManifestApplied", + message: "manifests of addon are applied successfully", + lastTransitionTime: "2025-05-20T08:52:59Z" + } + ], + registrations: [ + { + signerName: "kubernetes.io/kube-apiserver-client", + subject: { + groups: [ + `system:open-cluster-management:cluster:${clusterName}:addon:managed-serviceaccount`, + "system:open-cluster-management:addon:managed-serviceaccount", + "system:authenticated" + ], + user: `system:open-cluster-management:cluster:${clusterName}:addon:managed-serviceaccount:agent:addon-agent` + } + } + ], + supportedConfigs: [ + { + group: "addon.open-cluster-management.io", + resource: "addondeploymentconfigs" + } + ] + }, + { + id: `${clusterName}-application-manager`, + name: 'application-manager', + namespace: clusterName, + installNamespace: 'open-cluster-management-agent-addon', + creationTimestamp: "2025-05-20T08:52:35Z", + conditions: [ + { + type: "Available", + status: "True", + reason: "ManagedClusterAddOnLeaseUpdated", + message: "application-manager add-on is available.", + lastTransitionTime: "2025-05-20T08:53:15Z" + } + ] + }, + { + id: `${clusterName}-cert-policy-controller`, + name: 'cert-policy-controller', + namespace: clusterName, + installNamespace: 'open-cluster-management-agent-addon', + creationTimestamp: "2025-05-20T08:52:35Z", + conditions: [ + { + type: "Available", + status: "True", + reason: "ManagedClusterAddOnLeaseUpdated", + message: "cert-policy-controller add-on is available.", + lastTransitionTime: "2025-05-20T08:53:15Z" + } + ] + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clusters/${clusterName}/addons`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching addons for cluster ${clusterName}:`, error); + return []; + } +}; + +// Fetch a single addon by name for a specific cluster +export const fetchClusterAddonByName = async (clusterName: string, addonName: string): Promise => { + try { + const response = await fetch(`${API_BASE}/api/clusters/${clusterName}/addons/${addonName}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching addon ${addonName} for cluster ${clusterName}:`, error); + return null; + } +}; \ No newline at end of file diff --git a/dashboard/src/api/clusterService.ts b/dashboard/src/api/clusterService.ts new file mode 100644 index 00000000..96b6f96d --- /dev/null +++ b/dashboard/src/api/clusterService.ts @@ -0,0 +1,396 @@ +export interface Cluster { + id: string; + name: string; + status: string; // "Online" or "Offline" based on ManagedClusterConditionAvailable + version?: string; // Kubernetes version from status.version.kubernetes + conditions?: { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }[]; + labels?: Record; + hubAccepted?: boolean; // Based on spec.hubAcceptsClient or HubAcceptedManagedCluster condition + capacity?: Record; // From status.capacity + allocatable?: Record; // From status.allocatable + clusterClaims?: { // From status.clusterClaims + name: string; + value: string; + }[]; + managedClusterClientConfigs?: { + url: string; + caBundle?: string; + }[]; // From spec.managedClusterClientConfigs + taints?: { + key: string; + value?: string; + effect: string; + }[]; + creationTimestamp?: string; // From metadata.creationTimestamp + // Addon info for list page + addonCount?: number; + addonNames?: string[]; + // Removing nodes field as it's not available in ManagedCluster +} + +// Make sure we also export a type to avoid compiler issues +export type { Cluster as ClusterType }; + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Import the shared header creation function +import { createHeaders } from './utils'; + +// Fetch all clusters +export const fetchClusters = async (): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "mock-cluster-1", + name: "mock-cluster-1", + status: "Online", + version: "4.12.0", + hubAccepted: true, + creationTimestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + labels: { + vendor: "OpenShift", + region: "us-east-1", + env: "development", + tier: "gold" + }, + clusterClaims: [ + { + name: "usage", + value: "dev" + }, + { + name: "platform.open-cluster-management.io", + value: "AWS" + }, + { + name: "product.open-cluster-management.io", + value: "OpenShift" + } + ], + managedClusterClientConfigs: [ + { + url: "https://cluster1-control-plane:6443", + caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWEZtWkR0bjdXM2N3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBMU1UUXdPVEk1TWpoYUZ3MHpOVEExTVRJd09UTTBNamhhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUM2N0FXYSt2b1FQaE8xd05xUXdncjZxT0tuWW1hOWNTT0NCMHFTVW1VQUh0T29wSG1LWXArNzFMR1kKT0RXODB3M1FnMUJkTWw5Y0h1UVBjK043MTJsbzQwVVJMcDVCOEhoR2ZiZWlZOVhlWWZIYkRMdWpaV2tSaHI0agpOckNUcWRCN1JUYmhSY1NPKyszVVlGRG8ybVpSdmVBbGFyc25ldXJFNW5LL2RITU1Xb0hYL1VUcXBhc2RaTTZaCkVJaVNseldGUVYxWnpjTVBNVmZ4WjhlT1FWZjVqdHY4NnNhOTc1aFFhOG1WYXh6QTdjTzdiNTJYM200cXhuUWwKK1Voa1dTSC9GWXlEdE9vd3NFSDYvd25LRWY1Y3NiWFpJK2RGQ3EwWjU1b0JrbGcyMDlhSEJPOGUzYm1lZWE1dwpYQ1NBd2JpWm1wM0p1a203ODN5dkRyUWZodTRGQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSd3NlVXh4cHNvOE1qNlZ4Wnl4RDUyYVU5K1pEQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQVVYZFFPN2NSMQpJWUhXVkxEZ0JFUTdJRUJqcjYrSS9MbCt0bzF1STZiQ3o0dmxmMEJ6ZnBaQllCQmFxdzM5dERtaGcwUys5ZnEvClFyL1ZMUHlLeUpuOC9zdmQzbjUzRy9pNC9HM2JGcVc4azc3M3hSK3hkV21TcnAybEFnRGFEU0cxZVlUUEZFN3UKZTQ4T01WcGNRaHNEbmRZY2ExNnJ6LzZ5WlpONkxiY0dXbUV6bEtxN1EyamVsaGNwZnpSWjlqMGJxRTRNSmg1Rgo5cEY2encyMnNKd2pvanhVQzMyVHNGczN4bndMMDRuUDREcHM2TkJBbTFXWmlxbTJJSDJHWHh4SVVFbVpOUWZmCmxET3hIdGJONU5yR2xRVUdrWDJQdUpyOXdFS0lOSWtYaHlYS0tvRngzUFg3d2VSWnB6TWZOR2UrU0JUVkFjTmkKbi9IMjdRODF0L3orCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ], + capacity: { + cpu: "12", + memory: "32Gi" + }, + allocatable: { + cpu: "10", + memory: "28Gi" + }, + + conditions: [ + { + type: "ManagedClusterConditionAvailable", + status: "True", + reason: "ClusterAvailable", + message: "Cluster is available", + lastTransitionTime: new Date().toISOString() + }, + { + type: "ManagedClusterJoined", + status: "True", + reason: "ClusterJoined", + message: "Cluster has joined the hub", + lastTransitionTime: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() + } + ] + }, + { + id: "mock-cluster-2", + name: "mock-cluster-2", + status: "Offline", + version: "4.11.0", + hubAccepted: true, + creationTimestamp: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + labels: { + vendor: "OpenShift", + region: "us-west-1", + env: "staging", + tier: "silver" + }, + clusterClaims: [ + { + name: "usage", + value: "staging" + }, + { + name: "platform.open-cluster-management.io", + value: "GCP" + }, + { + name: "product.open-cluster-management.io", + value: "OpenShift" + } + ], + managedClusterClientConfigs: [ + { + url: "https://cluster2-control-plane:6443", + caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWnhLblFMVFovaG93RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBMU1UUXdPVEk1TXpsYUZ3MHpOVEExTVRJd09UTTBNemxhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUURndFVaM0JTT3pNWGZWZ3hZM3dpSGh5UGlqVU1Jb3JvYmRaY2FldDlLTnBqcU9RRHloQ05tTzAya1QKeGFkT1RtY0dJMmtPeDNvUE9PRGorWkd3cndXNjdtV0dTeTVHTGI5SlJJc1VydWZ4Rkt3cHk1L291dzBZU3lUVwphMkVNTmp1TS9TYmxHdE5lZHRaRkRVYXY5K015ejU2ZjBEZm1XdlRGNlNudEJLOGNLNEdYUXlPOGFzaC8xL1hOClRYQ2IxbjJldmllUlRiclp3aTR0d2kyQmFBVlc0dTArWmU0TWJaU3h1U01rL2t1UG02TXhVZUdHSXpUY1F2RXUKdjBzSDVPejRqeXRLbGsyR2Z1SXVwSXNQbGVrVWN5dS9wZnpvY0hmZlNpMVpla3YyNW1CMzlWN256TXZONWRjNAo2VXhPbjBjZGIvMU1xenhCVENjL0dDSGR1OHJaQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJTSDNmVWdXdG9UTEhYK2ZKUmZScnhuNVViUFd6QVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1hHd1Fzcjdpbgo3aXlqL3VCZTVPOTR6NVJMck0vZWR4U1M4ZkFIMzJrR2t6d0lzOFdoZUZJVHZuTC96UzBUY0Q4cll0Z3dmSThvCkE2WE1PaGxFVlJML0trQldFR2xLN0dyV0gva2orcjdpRjdTN2FoMzdRQUFSeTlCcGhPc1U1eERyaFAzN2gyMlYKeUVnQjhiWDJJcHJXdEwxZDhTeEVVRHFPMlV3a1VaVmIyK1RtV0lCMnpsT01CU0hjQ016VVNESWx4WTdPSzZXNgpTQ3djSmdtek1uWDFnMUQyZXRGM0p4eW5PU2k4VEoyejRLbFlZQk9tQ01uTHovaWIwVjNHMTNkRVVZamt0YXdxCmp2bHJuM2x5OVNDUThsZUdzNmVLTW4xYUNZZ2dpeUl6MllMbW45bEhHSUhJNU05Y0o1Z2lXcDlPVHM5MUt6d3EKb3FFcVRxaFBHZnhxCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ], + capacity: { + cpu: "24", + memory: "64Gi" + }, + allocatable: { + cpu: "20", + memory: "56Gi" + }, + + conditions: [ + { + type: "ManagedClusterConditionAvailable", + status: "False", + reason: "ClusterOffline", + message: "Cluster is not responding", + lastTransitionTime: new Date().toISOString() + } + ], + taints: [ + { + key: "cluster.open-cluster-management.io/unavailable", + effect: "NoSelect" + } + ] + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clusters`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching clusters:', error); + return []; + } +}; + +// Fetch a single cluster by name +export const fetchClusterByName = async (name: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + if (name === "mock-cluster-1") { + resolve({ + id: "mock-cluster-1", + name: "mock-cluster-1", + status: "Online", + version: "4.12.0", + hubAccepted: true, + creationTimestamp: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(), + labels: { + vendor: "OpenShift", + region: "us-east-1", + env: "development", + tier: "gold" + }, + clusterClaims: [ + { + name: "usage", + value: "dev" + }, + { + name: "platform.open-cluster-management.io", + value: "AWS" + }, + { + name: "product.open-cluster-management.io", + value: "OpenShift" + } + ], + managedClusterClientConfigs: [ + { + url: "https://cluster1-control-plane:6443", + caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWEZtWkR0bjdXM2N3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBMU1UUXdPVEk1TWpoYUZ3MHpOVEExTVRJd09UTTBNamhhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUUM2N0FXYSt2b1FQaE8xd05xUXdncjZxT0tuWW1hOWNTT0NCMHFTVW1VQUh0T29wSG1LWXArNzFMR1kKT0RXODB3M1FnMUJkTWw5Y0h1UVBjK043MTJsbzQwVVJMcDVCOEhoR2ZiZWlZOVhlWWZIYkRMdWpaV2tSaHI0agpOckNUcWRCN1JUYmhSY1NPKyszVVlGRG8ybVpSdmVBbGFyc25ldXJFNW5LL2RITU1Xb0hYL1VUcXBhc2RaTTZaCkVJaVNseldGUVYxWnpjTVBNVmZ4WjhlT1FWZjVqdHY4NnNhOTc1aFFhOG1WYXh6QTdjTzdiNTJYM200cXhuUWwKK1Voa1dTSC9GWXlEdE9vd3NFSDYvd25LRWY1Y3NiWFpJK2RGQ3EwWjU1b0JrbGcyMDlhSEJPOGUzYm1lZWE1dwpYQ1NBd2JpWm1wM0p1a203ODN5dkRyUWZodTRGQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJSd3NlVXh4cHNvOE1qNlZ4Wnl4RDUyYVU5K1pEQVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQVVYZFFPN2NSMQpJWUhXVkxEZ0JFUTdJRUJqcjYrSS9MbCt0bzF1STZiQ3o0dmxmMEJ6ZnBaQllCQmFxdzM5dERtaGcwUys5ZnEvClFyL1ZMUHlLeUpuOC9zdmQzbjUzRy9pNC9HM2JGcVc4azc3M3hSK3hkV21TcnAybEFnRGFEU0cxZVlUUEZFN3UKZTQ4T01WcGNRaHNEbmRZY2ExNnJ6LzZ5WlpONkxiY0dXbUV6bEtxN1EyamVsaGNwZnpSWjlqMGJxRTRNSmg1Rgo5cEY2encyMnNKd2pvanhVQzMyVHNGczN4bndMMDRuUDREcHM2TkJBbTFXWmlxbTJJSDJHWHh4SVVFbVpOUWZmCmxET3hIdGJONU5yR2xRVUdrWDJQdUpyOXdFS0lOSWtYaHlYS0tvRngzUFg3d2VSWnB6TWZOR2UrU0JUVkFjTmkKbi9IMjdRODF0L3orCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ], + capacity: { + cpu: "12", + memory: "32Gi" + }, + allocatable: { + cpu: "10", + memory: "28Gi" + }, + + conditions: [ + { + type: "ManagedClusterConditionAvailable", + status: "True", + reason: "ClusterAvailable", + message: "Cluster is available", + lastTransitionTime: new Date().toISOString() + }, + { + type: "ManagedClusterJoined", + status: "True", + reason: "ClusterJoined", + message: "Cluster has joined the hub", + lastTransitionTime: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() + }, + { + type: "HubAcceptedManagedCluster", + status: "True", + reason: "HubClusterAdminAccepted", + message: "Cluster has been accepted by hub", + lastTransitionTime: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString() + } + ] + }); + } else if (name === "mock-cluster-2") { + resolve({ + id: "mock-cluster-2", + name: "mock-cluster-2", + status: "Offline", + version: "4.11.0", + hubAccepted: true, + creationTimestamp: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000).toISOString(), + labels: { + vendor: "OpenShift", + region: "us-west-1", + env: "staging", + tier: "silver" + }, + clusterClaims: [ + { + name: "usage", + value: "staging" + }, + { + name: "platform.open-cluster-management.io", + value: "GCP" + }, + { + name: "product.open-cluster-management.io", + value: "OpenShift" + } + ], + managedClusterClientConfigs: [ + { + url: "https://cluster2-control-plane:6443", + caBundle: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURCVENDQWUyZ0F3SUJBZ0lJWnhLblFMVFovaG93RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TlRBMU1UUXdPVEk1TXpsYUZ3MHpOVEExTVRJd09UTTBNemxhTUJVeApFekFSQmdOVkJBTVRDbXQxWW1WeWJtVjBaWE13Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLCkFvSUJBUURndFVaM0JTT3pNWGZWZ3hZM3dpSGh5UGlqVU1Jb3JvYmRaY2FldDlLTnBqcU9RRHloQ05tTzAya1QKeGFkT1RtY0dJMmtPeDNvUE9PRGorWkd3cndXNjdtV0dTeTVHTGI5SlJJc1VydWZ4Rkt3cHk1L291dzBZU3lUVwphMkVNTmp1TS9TYmxHdE5lZHRaRkRVYXY5K015ejU2ZjBEZm1XdlRGNlNudEJLOGNLNEdYUXlPOGFzaC8xL1hOClRYQ2IxbjJldmllUlRiclp3aTR0d2kyQmFBVlc0dTArWmU0TWJaU3h1U01rL2t1UG02TXhVZUdHSXpUY1F2RXUKdjBzSDVPejRqeXRLbGsyR2Z1SXVwSXNQbGVrVWN5dS9wZnpvY0hmZlNpMVpla3YyNW1CMzlWN256TXZONWRjNAo2VXhPbjBjZGIvMU1xenhCVENjL0dDSGR1OHJaQWdNQkFBR2pXVEJYTUE0R0ExVWREd0VCL3dRRUF3SUNwREFQCkJnTlZIUk1CQWY4RUJUQURBUUgvTUIwR0ExVWREZ1FXQkJTSDNmVWdXdG9UTEhYK2ZKUmZScnhuNVViUFd6QVYKQmdOVkhSRUVEakFNZ2dwcmRXSmxjbTVsZEdWek1BMEdDU3FHU0liM0RRRUJDd1VBQTRJQkFRQ1hHd1Fzcjdpbgo3aXlqL3VCZTVPOTR6NVJMck0vZWR4U1M4ZkFIMzJrR2t6d0lzOFdoZUZJVHZuTC96UzBUY0Q4cll0Z3dmSThvCkE2WE1PaGxFVlJML0trQldFR2xLN0dyV0gva2orcjdpRjdTN2FoMzdRQUFSeTlCcGhPc1U1eERyaFAzN2gyMlYKeUVnQjhiWDJJcHJXdEwxZDhTeEVVRHFPMlV3a1VaVmIyK1RtV0lCMnpsT01CU0hjQ016VVNESWx4WTdPSzZXNgpTQ3djSmdtek1uWDFnMUQyZXRGM0p4eW5PU2k4VEoyejRLbFlZQk9tQ01uTHovaWIwVjNHMTNkRVVZamt0YXdxCmp2bHJuM2x5OVNDUThsZUdzNmVLTW4xYUNZZ2dpeUl6MllMbW45bEhHSUhJNU05Y0o1Z2lXcDlPVHM5MUt6d3EKb3FFcVRxaFBHZnhxCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K" + } + ], + capacity: { + cpu: "24", + memory: "64Gi" + }, + allocatable: { + cpu: "20", + memory: "56Gi" + }, + + conditions: [ + { + type: "ManagedClusterConditionAvailable", + status: "False", + reason: "ClusterOffline", + message: "Cluster is not responding", + lastTransitionTime: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + }, + { + type: "ManagedClusterJoined", + status: "True", + reason: "ClusterJoined", + message: "Cluster has joined the hub", + lastTransitionTime: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString() + } + ], + taints: [ + { + key: "cluster.open-cluster-management.io/unavailable", + effect: "NoSelect" + } + ] + }); + } else { + resolve(null); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clusters/${name}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching cluster ${name}:`, error); + return null; + } +}; + +// SSE for real-time cluster updates +export const setupClusterEventSource = ( + onAdd: (cluster: Cluster) => void, + onUpdate: (cluster: Cluster) => void, + onDelete: (clusterId: string) => void, + onError: (error: Event) => void +): () => void => { + // Use no-op in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return () => {}; // Return no-op cleanup function + } + + // Create EventSource for SSE + const token = localStorage.getItem('authToken'); + // Extract the token part without 'Bearer ' prefix for URL parameter + const tokenParam = token ? token.replace('Bearer ', '') : ''; + const eventSource = new EventSource( + `${API_BASE}/api/stream/clusters${tokenParam ? `?token=${tokenParam}` : ''}` + ); + + // Set up event listeners + eventSource.addEventListener('ADDED', (event) => { + const cluster = JSON.parse(event.data); + onAdd(cluster); + }); + + eventSource.addEventListener('MODIFIED', (event) => { + const cluster = JSON.parse(event.data); + onUpdate(cluster); + }); + + eventSource.addEventListener('DELETED', (event) => { + const clusterId = JSON.parse(event.data).id; + onDelete(clusterId); + }); + + eventSource.addEventListener('error', onError); + + // Return cleanup function to close the SSE connection + return () => { + eventSource.close(); + }; +}; \ No newline at end of file diff --git a/dashboard/src/api/clusterSetBindingService.ts b/dashboard/src/api/clusterSetBindingService.ts new file mode 100644 index 00000000..4b5380cb --- /dev/null +++ b/dashboard/src/api/clusterSetBindingService.ts @@ -0,0 +1,262 @@ +import { createHeaders } from './utils'; + +export interface ClusterSetBinding { + id: string; + name: string; + namespace: string; + creationTimestamp?: string; + spec: { + clusterSet: string; + }; + status?: { + conditions?: { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }[]; + }; +} + +export interface ManagedClusterSetBinding { + id: string; + name: string; + namespace: string; + clusterSet: string; + bound: boolean; + creationTimestamp?: string; +} + +// Raw API response interface +interface RawBindingData { + id?: string; + uid?: string; + name: string; + namespace: string; + creationTimestamp?: string; + spec?: { + clusterSet?: string; + }; + status?: { + conditions?: Array<{ + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }>; + }; +} + +// Make sure we also export a type to avoid compiler issues +export type { ClusterSetBinding as ClusterSetBindingType }; + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Fetch all cluster set bindings for a namespace +export const fetchNamespaceClusterSetBindings = async (namespace: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "default-binding", + name: "default-binding", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSet: "default" + }, + status: { + conditions: [ + { + type: "BindingValid", + status: "True", + reason: "BindingValid", + message: "Binding to ManagedClusterSet default is valid", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + }, + { + id: "global-binding", + name: "global-binding", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSet: "global" + }, + status: { + conditions: [ + { + type: "BindingValid", + status: "True", + reason: "BindingValid", + message: "Binding to ManagedClusterSet global is valid", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/clustersetbindings`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching cluster set bindings:', error); + return []; + } +}; + +// Fetch a single cluster set binding by name and namespace +export const fetchClusterSetBindingByName = async (namespace: string, name: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + if (name === "default-binding") { + resolve({ + id: "default-binding", + name: "default-binding", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSet: "default" + }, + status: { + conditions: [ + { + type: "BindingValid", + status: "True", + reason: "BindingValid", + message: "Binding to ManagedClusterSet default is valid", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + }); + } else if (name === "global-binding") { + resolve({ + id: "global-binding", + name: "global-binding", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSet: "global" + }, + status: { + conditions: [ + { + type: "BindingValid", + status: "True", + reason: "BindingValid", + message: "Binding to ManagedClusterSet global is valid", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + }); + } else { + resolve(null); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/clustersetbindings/${name}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching cluster set binding ${namespace}/${name}:`, error); + return null; + } +}; + +// Fetch all cluster set bindings across all namespaces +export const fetchClusterSetBindings = async (): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "default-default", + name: "default", + namespace: "default", + clusterSet: "default", + bound: true, + creationTimestamp: "2025-05-20T13:51:37Z" + }, + { + id: "open-cluster-management-addon-global", + name: "global", + namespace: "open-cluster-management-addon", + clusterSet: "global", + bound: true, + creationTimestamp: "2025-05-20T08:52:35Z" + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clustersetbindings`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const data = await response.json() as RawBindingData[]; + + return data.map((binding: RawBindingData) => ({ + id: binding.id || binding.uid || `${binding.namespace}-${binding.name}`, + name: binding.name, + namespace: binding.namespace, + clusterSet: binding.spec?.clusterSet || '', + bound: binding.status?.conditions?.some((condition) => + condition.type === 'Bound' && condition.status === 'True' + ) || false, + creationTimestamp: binding.creationTimestamp + })); + } catch (error) { + console.error('Error fetching all cluster set bindings:', error); + return []; + } +}; + +// Fetch all bindings for a specific cluster set +export const fetchClusterSetBindingsByClusterSet = async (clusterSetName: string): Promise => { + try { + const allBindings = await fetchClusterSetBindings(); + return allBindings.filter(binding => binding.clusterSet === clusterSetName); + } catch (error) { + console.error(`Error fetching bindings for cluster set ${clusterSetName}:`, error); + return []; + } +}; \ No newline at end of file diff --git a/dashboard/src/api/clusterSetService.ts b/dashboard/src/api/clusterSetService.ts new file mode 100644 index 00000000..bcc71c63 --- /dev/null +++ b/dashboard/src/api/clusterSetService.ts @@ -0,0 +1,177 @@ +import { createHeaders } from './utils'; + +export interface ClusterSet { + id: string; + name: string; + labels?: Record; + creationTimestamp?: string; + spec?: { + clusterSelector: { + selectorType: string; + labelSelector?: { + matchLabels?: Record; + }; + }; + }; + status?: { + conditions?: { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }[]; + }; +} + +// Make sure we also export a type to avoid compiler issues +export type { ClusterSet as ClusterSetType }; + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Fetch all cluster sets +export const fetchClusterSets = async (): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "default", + name: "default", + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSelector: { + selectorType: "ExclusiveClusterSetLabel" + } + }, + status: { + conditions: [ + { + type: "ClusterSetEmpty", + status: "False", + reason: "ClustersSelected", + message: "2 ManagedClusters selected", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + }, + { + id: "global", + name: "global", + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSelector: { + selectorType: "LabelSelector", + labelSelector: {} + } + }, + status: { + conditions: [ + { + type: "ClusterSetEmpty", + status: "False", + reason: "ClustersSelected", + message: "2 ManagedClusters selected", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clustersets`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching cluster sets:', error); + return []; + } +}; + +// Fetch a single cluster set by name +export const fetchClusterSetByName = async (name: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + if (name === "default") { + resolve({ + id: "default", + name: "default", + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSelector: { + selectorType: "ExclusiveClusterSetLabel" + } + }, + status: { + conditions: [ + { + type: "ClusterSetEmpty", + status: "False", + reason: "ClustersSelected", + message: "2 ManagedClusters selected", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + }); + } else if (name === "global") { + resolve({ + id: "global", + name: "global", + creationTimestamp: "2025-05-14T09:35:54Z", + spec: { + clusterSelector: { + selectorType: "LabelSelector", + labelSelector: {} + } + }, + status: { + conditions: [ + { + type: "ClusterSetEmpty", + status: "False", + reason: "ClustersSelected", + message: "2 ManagedClusters selected", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + }); + } else { + resolve(null); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/clustersets/${name}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching cluster set ${name}:`, error); + return null; + } +}; diff --git a/dashboard/src/api/manifestWorkService.ts b/dashboard/src/api/manifestWorkService.ts new file mode 100644 index 00000000..714bd6c6 --- /dev/null +++ b/dashboard/src/api/manifestWorkService.ts @@ -0,0 +1,335 @@ +import { createHeaders } from './utils'; + +export interface ManifestWork { + id: string; + name: string; + namespace: string; + labels?: Record; + creationTimestamp?: string; + manifests?: Manifest[]; + conditions?: Condition[]; + resourceStatus?: ManifestResourceStatus; +} + +export interface Manifest { + rawExtension?: Record; +} + +export interface Condition { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; +} + +export interface ManifestResourceStatus { + manifests?: ManifestCondition[]; +} + +export interface ManifestCondition { + resourceMeta: ManifestResourceMeta; + conditions: Condition[]; +} + +export interface ManifestResourceMeta { + ordinal: number; + group?: string; + version?: string; + kind?: string; + resource?: string; + name?: string; + namespace?: string; +} + +// Make sure we also export a type to avoid compiler issues +export type { ManifestWork as ManifestWorkType }; + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Fetch all manifest works for a namespace +export const fetchManifestWorks = async (namespace: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "app-deployment-work", + name: "app-deployment", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + manifests: [ + { + rawExtension: { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "example-app", + namespace: "default" + }, + spec: { + replicas: 3 + } + } + } + ], + conditions: [ + { + type: "Applied", + status: "True", + reason: "WorkApplied", + message: "All resources applied successfully", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ], + resourceStatus: { + manifests: [ + { + resourceMeta: { + ordinal: 0, + group: "apps", + version: "v1", + kind: "Deployment", + resource: "deployments", + name: "example-app", + namespace: "default" + }, + conditions: [ + { + type: "Applied", + status: "True", + reason: "AppliedManifestComplete", + message: "Resource has been applied", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + ] + } + }, + { + id: "app-service-work", + name: "app-service", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + manifests: [ + { + rawExtension: { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "example-service", + namespace: "default" + }, + spec: { + ports: [ + { + port: 80, + targetPort: 8080 + } + ] + } + } + } + ], + conditions: [ + { + type: "Applied", + status: "True", + reason: "WorkApplied", + message: "All resources applied successfully", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ], + resourceStatus: { + manifests: [ + { + resourceMeta: { + ordinal: 0, + group: "", + version: "v1", + kind: "Service", + resource: "services", + name: "example-service", + namespace: "default" + }, + conditions: [ + { + type: "Applied", + status: "True", + reason: "AppliedManifestComplete", + message: "Resource has been applied", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + ] + } + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/manifestworks`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching manifest works for namespace ${namespace}:`, error); + return []; + } +}; + +// Fetch a single manifest work by name and namespace +export const fetchManifestWorkByName = async (namespace: string, name: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + if (name === "app-deployment") { + resolve({ + id: "app-deployment-work", + name: "app-deployment", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + manifests: [ + { + rawExtension: { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: "example-app", + namespace: "default" + }, + spec: { + replicas: 3 + } + } + } + ], + conditions: [ + { + type: "Applied", + status: "True", + reason: "WorkApplied", + message: "All resources applied successfully", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ], + resourceStatus: { + manifests: [ + { + resourceMeta: { + ordinal: 0, + group: "apps", + version: "v1", + kind: "Deployment", + resource: "deployments", + name: "example-app", + namespace: "default" + }, + conditions: [ + { + type: "Applied", + status: "True", + reason: "AppliedManifestComplete", + message: "Resource has been applied", + lastTransitionTime: "2025-05-14T09:37:25Z" + } + ] + } + ] + } + }); + } else if (name === "app-service") { + resolve({ + id: "app-service-work", + name: "app-service", + namespace: namespace, + creationTimestamp: "2025-05-14T09:35:54Z", + manifests: [ + { + rawExtension: { + apiVersion: "v1", + kind: "Service", + metadata: { + name: "example-service", + namespace: "default" + }, + spec: { + ports: [ + { + port: 80, + targetPort: 8080 + } + ] + } + } + } + ], + conditions: [ + { + type: "Applied", + status: "True", + reason: "WorkApplied", + message: "All resources applied successfully", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ], + resourceStatus: { + manifests: [ + { + resourceMeta: { + ordinal: 0, + group: "", + version: "v1", + kind: "Service", + resource: "services", + name: "example-service", + namespace: "default" + }, + conditions: [ + { + type: "Applied", + status: "True", + reason: "AppliedManifestComplete", + message: "Resource has been applied", + lastTransitionTime: "2025-05-14T09:36:18Z" + } + ] + } + ] + } + }); + } else { + resolve(null); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/manifestworks/${name}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching manifest work ${namespace}/${name}:`, error); + return null; + } +}; \ No newline at end of file diff --git a/dashboard/src/api/placementService.ts b/dashboard/src/api/placementService.ts new file mode 100644 index 00000000..569f1e1a --- /dev/null +++ b/dashboard/src/api/placementService.ts @@ -0,0 +1,878 @@ +import type { Cluster } from './clusterService'; +import { createHeaders } from './utils'; + +export interface PlacementDecision { + name: string; + namespace: string; + decisions: { + clusterName: string; + reason: string; + }[]; +} + +export interface Placement { + id: string; + name: string; + namespace: string; + creationTimestamp?: string; + + // Spec fields + clusterSets: string[]; + numberOfClusters?: number; + prioritizerPolicy?: { + mode: string; + configurations: { + scoreCoordinate: { + type: string; + builtIn?: string; + addOn?: { + resourceName: string; + scoreName: string; + } + }; + weight: number; + }[]; + }; + predicates?: { + requiredClusterSelector?: { + labelSelector?: { + matchLabels?: Record; + matchExpressions?: { + key: string; + operator: string; + values: string[]; + }[]; + }; + claimSelector?: { + matchExpressions?: { + key: string; + operator: string; + values: string[]; + }[]; + }; + celSelector?: { + celExpressions?: string[]; + }; + }; + }[]; + tolerations?: { + key?: string; + operator?: string; + value?: string; + effect?: string; + tolerationSeconds?: number; + }[]; + decisionStrategy?: { + groupStrategy?: { + decisionGroups?: { + groupName: string; + groupClusterSelector: { + labelSelector?: { + matchLabels?: Record; + matchExpressions?: { + key: string; + operator: string; + values: string[]; + }[]; + }; + }; + }[]; + clustersPerDecisionGroup?: string; + }; + }; + + // Status fields + numberOfSelectedClusters: number; + decisionGroups?: { + decisionGroupIndex: number; + decisionGroupName: string; + decisions: string[]; + clusterCount: number; + }[]; + conditions?: { + type: string; + status: string; + reason?: string; + message?: string; + lastTransitionTime?: string; + }[]; + + // Calculated fields + succeeded: boolean; // Based on PlacementSatisfied condition status + reasonMessage?: string; + selectedClusters?: Cluster[]; +} + +// Backend API base URL - configurable for production +// In production, use relative path so requests go through the same host/ingress +const API_BASE = import.meta.env.VITE_API_BASE || (import.meta.env.PROD ? '' : 'http://localhost:8080'); + +// Helper to determine if a placement is succeeded based on PlacementSatisfied condition +const determineSucceededStatus = (conditions?: { type: string; status: string }[]): boolean => { + if (!conditions || conditions.length === 0) return false; + + const satisfiedCondition = conditions.find(c => c.type === 'PlacementSatisfied'); + return satisfiedCondition?.status === 'True'; +}; + +// Fetch all placements +export const fetchPlacements = async (): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + id: "dd1845a6-4913-48ea-8784-24bf2fb1edf0", + name: "placement-label-claim", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: ["default"], + numberOfClusters: 1, + numberOfSelectedClusters: 1, + predicates: [ + { + requiredClusterSelector: { + claimSelector: { + matchExpressions: [ + { + key: "usage", + operator: "In", + values: ["dev"] + } + ] + }, + labelSelector: { + matchLabels: { + "feature.open-cluster-management.io/addon-managed-serviceaccount": "available" + } + } + } + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-label-claim-decision-1"], + clusterCount: 1 + } + ], + conditions: [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ], + succeeded: true + }, + { + id: "a5ca7369-0740-4b26-a8d5-77a097a3cfc9", + name: "placement-priority", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: [], + numberOfClusters: 1, + numberOfSelectedClusters: 1, + prioritizerPolicy: { + mode: "Additive", + configurations: [ + { + scoreCoordinate: { + type: "BuiltIn", + builtIn: "ResourceAllocatableMemory" + }, + weight: 1 + } + ] + }, + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-priority-decision-1"], + clusterCount: 1 + } + ], + conditions: [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ], + succeeded: true + }, + { + id: "0d39b430-8a78-46e1-b6fc-62b091196703", + name: "placement-tolerations", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: [], + numberOfSelectedClusters: 2, + tolerations: [ + { + key: "gpu", + value: "true", + operator: "Equal", + tolerationSeconds: 300 + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-tolerations-decision-1"], + clusterCount: 2 + } + ], + conditions: [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ], + succeeded: true + }, + { + id: "bcfd62a3-43a3-4717-9fce-3455631bbe82", + name: "global", + namespace: "open-cluster-management-addon", + creationTimestamp: "2025-05-20T08:52:35Z", + clusterSets: ["global"], + numberOfSelectedClusters: 2, + tolerations: [ + { + key: "cluster.open-cluster-management.io/unreachable", + operator: "Equal" + }, + { + key: "cluster.open-cluster-management.io/unavailable", + operator: "Equal" + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["global-decision-1"], + clusterCount: 2 + } + ], + conditions: [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T08:52:35Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T08:52:35Z" + } + ], + succeeded: true + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/placements`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const placements = await response.json(); + + // Add succeeded status to each placement based on the PlacementSatisfied condition + return placements.map((placement: Placement) => ({ + ...placement, + succeeded: determineSucceededStatus(placement.conditions) + })); + } catch (error) { + console.error('Error fetching placements:', error); + return []; + } +}; + +// Fetch a single placement by namespace and name +export const fetchPlacementByName = async ( + namespace: string, + name: string +): Promise => { + console.log(`API fetchPlacementByName called with namespace=${namespace}, name=${name}`); + + // Handle the case where URL parameters might include namespace + let actualNamespace = namespace; + let actualName = name; + + // Check if name contains a slash, which means it might be in "namespace/name" format + // But avoid re-parsing if it has already been processed in the component + if (!namespace.includes('_PARSED_') && name && name.includes('/')) { + const parts = name.split('/'); + if (parts.length === 2) { + actualNamespace = parts[0]; + actualName = parts[1]; + console.log(`Parsed name from URL: namespace=${actualNamespace}, name=${actualName}`); + } + } + + // Remove marker + if (actualNamespace.includes('_PARSED_')) { + actualNamespace = actualNamespace.replace('_PARSED_', ''); + } + + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + console.log(`Checking mock data for ${actualNamespace}/${actualName}`); + + if (actualName === "placement-label-claim" && actualNamespace === "default") { + console.log(`Returning mock data for ${actualNamespace}/${actualName}`); + const conditions = [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ]; + + resolve({ + id: "dd1845a6-4913-48ea-8784-24bf2fb1edf0", + name: "placement-label-claim", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: ["default"], + numberOfClusters: 1, + numberOfSelectedClusters: 1, + predicates: [ + { + requiredClusterSelector: { + claimSelector: { + matchExpressions: [ + { + key: "usage", + operator: "In", + values: ["dev"] + } + ] + }, + labelSelector: { + matchLabels: { + "feature.open-cluster-management.io/addon-managed-serviceaccount": "available" + } + } + } + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-label-claim-decision-1"], + clusterCount: 1 + } + ], + conditions, + succeeded: determineSucceededStatus(conditions), + selectedClusters: [ + { + id: "mock-cluster-1", + name: "mock-cluster-1", + status: "Online", + } + ], + }); + } else if (actualName === "placement-priority" && actualNamespace === "default") { + const priorityConditions = [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ]; + + resolve({ + id: "a5ca7369-0740-4b26-a8d5-77a097a3cfc9", + name: "placement-priority", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: [], + numberOfClusters: 1, + numberOfSelectedClusters: 1, + prioritizerPolicy: { + mode: "Additive", + configurations: [ + { + scoreCoordinate: { + type: "BuiltIn", + builtIn: "ResourceAllocatableMemory" + }, + weight: 1 + } + ] + }, + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-priority-decision-1"], + clusterCount: 1 + } + ], + conditions: priorityConditions, + succeeded: determineSucceededStatus(priorityConditions), + selectedClusters: [ + { + id: "mock-cluster-2", + name: "mock-cluster-2", + status: "Online", + } + ], + }); + } else if (actualName === "placement-tolerations" && actualNamespace === "default") { + const tolerationsConditions = [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T13:42:49Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T13:51:37Z" + } + ]; + + resolve({ + id: "0d39b430-8a78-46e1-b6fc-62b091196703", + name: "placement-tolerations", + namespace: "default", + creationTimestamp: "2025-05-20T13:42:49Z", + clusterSets: [], + numberOfSelectedClusters: 2, + tolerations: [ + { + key: "gpu", + value: "true", + operator: "Equal", + tolerationSeconds: 300 + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["placement-tolerations-decision-1"], + clusterCount: 2 + } + ], + conditions: tolerationsConditions, + succeeded: determineSucceededStatus(tolerationsConditions), + selectedClusters: [ + { + id: "mock-cluster-3", + name: "mock-cluster-3", + status: "Online", + }, + { + id: "mock-cluster-4", + name: "mock-cluster-4", + status: "Online", + } + ], + }); + } else if (actualName === "global" && actualNamespace === "open-cluster-management-addon") { + const globalConditions = [ + { + type: "PlacementMisconfigured", + status: "False", + reason: "Succeedconfigured", + message: "Placement configurations check pass", + lastTransitionTime: "2025-05-20T08:52:35Z" + }, + { + type: "PlacementSatisfied", + status: "True", + reason: "AllDecisionsScheduled", + message: "All cluster decisions scheduled", + lastTransitionTime: "2025-05-20T08:52:35Z" + } + ]; + + resolve({ + id: "bcfd62a3-43a3-4717-9fce-3455631bbe82", + name: "global", + namespace: "open-cluster-management-addon", + creationTimestamp: "2025-05-20T08:52:35Z", + clusterSets: ["global"], + numberOfSelectedClusters: 2, + tolerations: [ + { + key: "cluster.open-cluster-management.io/unreachable", + operator: "Equal" + }, + { + key: "cluster.open-cluster-management.io/unavailable", + operator: "Equal" + } + ], + decisionGroups: [ + { + decisionGroupIndex: 0, + decisionGroupName: "", + decisions: ["global-decision-1"], + clusterCount: 2 + } + ], + conditions: globalConditions, + succeeded: determineSucceededStatus(globalConditions), + selectedClusters: [ + { + id: "mock-cluster-5", + name: "mock-cluster-5", + status: "Online", + }, + { + id: "mock-cluster-6", + name: "mock-cluster-6", + status: "Online", + } + ], + }); + } else { + resolve(null); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${actualNamespace}/placements/${actualName}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + const placement = await response.json(); + + // Add succeeded status based on the PlacementSatisfied condition + return { + ...placement, + succeeded: determineSucceededStatus(placement.conditions) + }; + } catch (error) { + console.error(`Error fetching placement ${actualNamespace}/${actualName}:`, error); + return null; + } +}; + +// Fetch placement decisions for a placement +export const fetchPlacementDecisions = async ( + namespace: string, + placementName: string +): Promise => { + console.log(`API fetchPlacementDecisions called with namespace=${namespace}, placementName=${placementName}`); + + // Handle the case where URL parameters might include namespace + let actualNamespace = namespace; + let actualName = placementName; + + // Check if placementName contains a slash, which means it might be in "namespace/name" format + // But avoid re-parsing if it has already been processed in the component + if (!namespace.includes('_PARSED_') && placementName && placementName.includes('/')) { + const parts = placementName.split('/'); + if (parts.length === 2) { + actualNamespace = parts[0]; + actualName = parts[1]; + console.log(`Parsed name from URL: namespace=${actualNamespace}, name=${actualName}`); + } + } + + // Remove marker + if (actualNamespace.includes('_PARSED_')) { + actualNamespace = actualNamespace.replace('_PARSED_', ''); + } + + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + console.log(`Checking mock decisions for ${actualNamespace}/${actualName}`); + + if (actualName === "placement-label-claim" && actualNamespace === "default") { + console.log(`Returning mock decisions for ${actualNamespace}/${actualName}`); + resolve([ + { + name: "placement-label-claim-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-1", + reason: "Selected by placement" + } + ] + } + ]); + } else if (actualName === "placement-priority" && actualNamespace === "default") { + resolve([ + { + name: "placement-priority-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-2", + reason: "Selected by placement (highest ResourceAllocatableMemory)" + } + ] + } + ]); + } else if (actualName === "placement-tolerations" && actualNamespace === "default") { + resolve([ + { + name: "placement-tolerations-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-3", + reason: "Selected by placement" + }, + { + clusterName: "mock-cluster-4", + reason: "Selected by placement" + } + ] + } + ]); + } else if (actualName === "global" && actualNamespace === "open-cluster-management-addon") { + resolve([ + { + name: "global-decision-1", + namespace: "open-cluster-management-addon", + decisions: [ + { + clusterName: "mock-cluster-5", + reason: "Selected by placement" + }, + { + clusterName: "mock-cluster-6", + reason: "Selected by placement" + } + ] + } + ]); + } else { + resolve([]); + } + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${actualNamespace}/placements/${actualName}/placementdecisions`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching placement decisions for ${actualNamespace}/${actualName}:`, error); + return []; + } +}; + +// Fetch all placement decisions across all namespaces +export const fetchAllPlacementDecisions = async (): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + return new Promise((resolve) => { + setTimeout(() => { + resolve([ + { + name: "placement-label-claim-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-1", + reason: "Selected by placement" + } + ] + }, + { + name: "placement-priority-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-2", + reason: "Selected by placement (highest ResourceAllocatableMemory)" + } + ] + }, + { + name: "placement-tolerations-decision-1", + namespace: "default", + decisions: [ + { + clusterName: "mock-cluster-3", + reason: "Selected by placement" + }, + { + clusterName: "mock-cluster-4", + reason: "Selected by placement" + } + ] + }, + { + name: "global-decision-1", + namespace: "open-cluster-management-addon", + decisions: [ + { + clusterName: "mock-cluster-5", + reason: "Selected by placement" + }, + { + clusterName: "mock-cluster-6", + reason: "Selected by placement" + } + ] + } + ]); + }, 800); + }); + } + + try { + const response = await fetch(`${API_BASE}/api/placementdecisions`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Error fetching all placement decisions:', error); + return []; + } +}; + +// Fetch placement decisions by namespace +export const fetchPlacementDecisionsByNamespace = async (namespace: string): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + // Filter mock data by namespace + const allDecisions = await fetchAllPlacementDecisions(); + return allDecisions.filter(decision => decision.namespace === namespace); + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/placementdecisions`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching placement decisions for namespace ${namespace}:`, error); + return []; + } +}; + +// Fetch a specific placement decision by name and namespace +export const fetchPlacementDecisionByName = async ( + namespace: string, + name: string +): Promise => { + // Use mock data in development mode unless specifically requested to use real API + if (import.meta.env.DEV && !import.meta.env.VITE_USE_REAL_API) { + const allDecisions = await fetchAllPlacementDecisions(); + return allDecisions.find( + decision => decision.namespace === namespace && decision.name === name + ) || null; + } + + try { + const response = await fetch(`${API_BASE}/api/namespaces/${namespace}/placementdecisions/${name}`, { + headers: createHeaders() + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error(`Error fetching placement decision ${namespace}/${name}:`, error); + return null; + } +}; \ No newline at end of file diff --git a/dashboard/src/api/utils.ts b/dashboard/src/api/utils.ts new file mode 100644 index 00000000..02c465f9 --- /dev/null +++ b/dashboard/src/api/utils.ts @@ -0,0 +1,17 @@ +/** + * Create headers for API requests + * @returns Headers object with Authorization and Content-Type + */ +export const createHeaders = (): HeadersInit => { + const token = localStorage.getItem('authToken'); + const headers: HeadersInit = { + 'Content-Type': 'application/json', + }; + + if (token) { + // Token is already stored with 'Bearer ' prefix + headers['Authorization'] = token; + } + + return headers; +}; diff --git a/dashboard/src/auth/AuthContext.tsx b/dashboard/src/auth/AuthContext.tsx new file mode 100644 index 00000000..1c1b86c7 --- /dev/null +++ b/dashboard/src/auth/AuthContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useState, useContext, type ReactNode, useEffect } from 'react'; + + + +interface AuthContextType { + token: string | null; + isAuthenticated: boolean; + login: (token: string) => void; + logout: () => void; + isLoading: boolean; + error: string | null; +} + +const AuthContext = createContext(undefined); + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [token, setToken] = useState(() => { + return localStorage.getItem('authToken'); + }); + + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const isAuthenticated = !!token; + + useEffect(() => { + if (token) { + localStorage.setItem('authToken', token); + } else { + localStorage.removeItem('authToken'); + } + }, [token]); + + + + const login = (newToken: string) => { + setError(null); + // Clean and format the token + const cleanToken = newToken.trim(); + const formattedToken = cleanToken.startsWith('Bearer ') + ? cleanToken + : `Bearer ${cleanToken}`; + + setToken(formattedToken); + setIsLoading(false); + }; + + const logout = () => { + setToken(null); + setError(null); + setIsLoading(false); + }; + + const value = { + token, + isAuthenticated, + login, + logout, + isLoading, + error + }; + + return {children}; +}; + +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; \ No newline at end of file diff --git a/dashboard/src/components/ClusterAddonsList.tsx b/dashboard/src/components/ClusterAddonsList.tsx new file mode 100644 index 00000000..dd360ddb --- /dev/null +++ b/dashboard/src/components/ClusterAddonsList.tsx @@ -0,0 +1,247 @@ +import { + Box, + Typography, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Collapse, + IconButton, + Chip, + CircularProgress, + Alert, + Card, + CardHeader, + CardContent +} from '@mui/material'; +import type { ManagedClusterAddon } from '../api/addonService'; +import { useState } from 'react'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; + +interface AddonRowProps { + addon: ManagedClusterAddon; +} + +// Format date +const formatDate = (dateString?: string) => { + if (!dateString) return 'Unknown'; + return new Date(dateString).toLocaleString('en-US'); +}; + +// Get status of an addon based on conditions +const getAddonStatus = (addon: ManagedClusterAddon): { status: string; color: 'success' | 'error' | 'warning' | 'default' } => { + if (!addon.conditions || addon.conditions.length === 0) { + return { status: 'Unknown', color: 'default' }; + } + + const availableCondition = addon.conditions.find(c => c.type === 'Available'); + if (availableCondition) { + if (availableCondition.status === 'True') { + return { status: 'Available', color: 'success' }; + } else { + return { status: 'Unavailable', color: 'error' }; + } + } + + const progressingCondition = addon.conditions.find(c => c.type === 'Progressing'); + if (progressingCondition && progressingCondition.status === 'True') { + return { status: 'Progressing', color: 'warning' }; + } + + return { status: 'Unknown', color: 'default' }; +}; + +// Component for a single addon row with expandable details +function AddonRow({ addon }: AddonRowProps) { + const [open, setOpen] = useState(false); + const addonStatus = getAddonStatus(addon); + + return ( + <> + *': { borderBottom: 'unset' } }}> + + setOpen(!open)} + > + {open ? : } + + + + + {addon.name} + + + + + + {addon.installNamespace} + {formatDate(addon.creationTimestamp)} + + + + + + {addon.registrations && addon.registrations.length > 0 && ( + <> + + Registrations + + {addon.registrations.map((registration, idx) => ( + + Signer: {registration.signerName}} + sx={{ pb: 0 }} + /> + + + User: {registration.subject.user} + + + Groups: + + + {registration.subject.groups.map((group, gIdx) => ( + + • {group} + + ))} + + + + ))} + + )} + + {addon.supportedConfigs && addon.supportedConfigs.length > 0 && ( + <> + + Supported Configurations + + + + + + Group + Resource + + + + {addon.supportedConfigs.map((config, idx) => ( + + {config.group} + {config.resource} + + ))} + +
+
+ + )} + + + Conditions + + {addon.conditions && addon.conditions.length > 0 ? ( + + + + + Type + Status + Reason + Message + Last Transition + + + + {addon.conditions.map((condition, index) => ( + + {condition.type} + + + + {condition.reason || '-'} + {condition.message || '-'} + {formatDate(condition.lastTransitionTime)} + + ))} + +
+
+ ) : ( + No conditions available + )} +
+
+
+
+ + ); +} + +interface ClusterAddonsListProps { + addons: ManagedClusterAddon[]; + loading: boolean; + error: string | null; +} + +export default function ClusterAddonsList({ addons, loading, error }: ClusterAddonsListProps) { + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error loading add-ons: {error} + + ); + } + + if (addons.length === 0) { + return ( + + No add-ons found for this cluster. + + ); + } + + return ( + + + + + + Name + Status + Install Namespace + Created + + + + {addons.map((addon) => ( + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/ClusterDetailContent.tsx b/dashboard/src/components/ClusterDetailContent.tsx new file mode 100644 index 00000000..ff9ff91e --- /dev/null +++ b/dashboard/src/components/ClusterDetailContent.tsx @@ -0,0 +1,383 @@ +import { + Box, + Typography, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Chip, + Tabs, + Tab, +} from '@mui/material'; +import type { Cluster } from '../api/clusterService'; +import { useState } from 'react'; +import ClusterAddonsList from './ClusterAddonsList'; +import ClusterManifestWorksList from './ClusterManifestWorksList'; +import { useClusterAddons } from '../hooks/useClusterAddons'; +import { useClusterManifestWorks } from '../hooks/useClusterManifestWorks'; + +interface ClusterDetailContentProps { + cluster: Cluster; + compact?: boolean; +} + +/** + * Component for displaying cluster details + * Can be used in both drawer and page layouts + */ +// 格式化内存和存储大小为人类可读格式 +const formatResourceSize = (value: string | undefined): string => { + if (!value) return '-'; + + // 处理带有单位的字符串,如 "16417196Ki" + const match = value.match(/^(\d+)(\w+)$/); + if (!match) return value; + + const [, numStr, unit] = match; + const num = parseInt(numStr, 10); + + // 根据单位进行转换 + switch (unit) { + case 'Ki': // Kibibytes + if (num > 1024 * 1024) { + return `${(num / (1024 * 1024)).toFixed(2)} GiB`; + } else if (num > 1024) { + return `${(num / 1024).toFixed(2)} MiB`; + } + return `${num} KiB`; + + case 'Mi': // Mebibytes + if (num > 1024) { + return `${(num / 1024).toFixed(2)} GiB`; + } + return `${num} MiB`; + + case 'Gi': // Gibibytes + return `${num} GiB`; + + case 'Ti': // Tebibytes + return `${num} TiB`; + + default: + return value; + } +}; + +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +function TabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} + +function a11yProps(index: number) { + return { + id: `simple-tab-${index}`, + 'aria-controls': `simple-tabpanel-${index}`, + }; +} + +export default function ClusterDetailContent({ cluster, compact = false }: ClusterDetailContentProps) { + const [tabValue, setTabValue] = useState(0); + const { addons, loading, error } = useClusterAddons(cluster.name); + const { manifestWorks, loading: manifestWorksLoading, error: manifestWorksError } = useClusterManifestWorks(cluster.name); + + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setTabValue(newValue); + }; + + // Get status text + const getStatusText = (status: string) => { + return status; + }; + + // Format date + const formatDate = (dateString?: string) => { + if (!dateString) return 'Unknown'; + return new Date(dateString).toLocaleString('en-US'); + }; + + return ( + + {!compact && ( + + + + Overview + + } + {...a11yProps(0)} + /> + + Add-ons + + } + {...a11yProps(1)} + /> + + ManifestWorks + + } + {...a11yProps(2)} + /> + + + )} + + + {/* Basic information */} + + + + + Cluster ID + + {cluster.id} + + + + Status + + {getStatusText(cluster.status)} + + + + Created + + + {cluster.creationTimestamp + ? new Date(cluster.creationTimestamp).toLocaleString('en-US') + : 'Unknown'} + + + + + Hub Accepted + + {cluster.hubAccepted ? 'Yes' : 'No'} + + + + Kubernetes Version + + {cluster.version || 'Unknown'} + + + + Last Updated + + + {formatDate(cluster.conditions?.[0]?.lastTransitionTime)} + + + + + Labels + + {cluster.labels ? ( + + {Object.entries(cluster.labels).map(([key, value]) => ( + + ))} + + ) : ( + - + )} + + + + Cluster Claims + + {cluster.clusterClaims && cluster.clusterClaims.length > 0 ? ( + + {cluster.clusterClaims.map((claim) => ( + + ))} + + ) : ( + - + )} + + + + + {/* Cluster URL information if available */} + {cluster.managedClusterClientConfigs && cluster.managedClusterClientConfigs.length > 0 && ( + + + Cluster URL Information + + + + + + URL + + + + {cluster.managedClusterClientConfigs.map((config, index) => ( + + {config.url} + + ))} + +
+
+
+ )} + + {/* Resource information if available */} + {(cluster.capacity || cluster.allocatable) && ( + + + Resource Information + + + + + + Resource + Capacity + Allocatable + + + + + CPU + {cluster.capacity?.cpu || '-'} + {cluster.allocatable?.cpu || '-'} + + + Memory + {formatResourceSize(cluster.capacity?.memory)} + {formatResourceSize(cluster.allocatable?.memory)} + + {cluster.capacity?.['ephemeral-storage'] && ( + + Storage + {formatResourceSize(cluster.capacity?.['ephemeral-storage'])} + {formatResourceSize(cluster.allocatable?.['ephemeral-storage'])} + + )} + {cluster.capacity?.pods && ( + + Pods + {cluster.capacity?.pods || '-'} + {cluster.allocatable?.pods || '-'} + + )} + +
+
+
+ )} + + {/* Conditions information */} + + + + Conditions + + + + {/* Conditions content */} + {cluster.conditions && cluster.conditions.length > 0 && ( + + + + + + Type + Status + Reason + Message + Last Updated + + + + {cluster.conditions.map((condition, index) => ( + + {condition.type} + + + + {condition.reason || '-'} + {condition.message || '-'} + {formatDate(condition.lastTransitionTime)} + + ))} + +
+
+
+ )} +
+
+ + + + + + + + + + + + + + + + + + ); +} diff --git a/dashboard/src/components/ClusterDetailPage.tsx b/dashboard/src/components/ClusterDetailPage.tsx new file mode 100644 index 00000000..2b438ee1 --- /dev/null +++ b/dashboard/src/components/ClusterDetailPage.tsx @@ -0,0 +1,53 @@ +import { useParams } from 'react-router-dom'; +import { Box, CircularProgress, Typography, Button } from '@mui/material'; +import { useCluster } from '../hooks/useCluster'; +import ClusterDetailContent from './ClusterDetailContent'; +import PageLayout from './layout/PageLayout'; + +/** + * Page component for displaying cluster details + */ +export default function ClusterDetailPage() { + const { name } = useParams<{ name: string }>(); + const { cluster, loading, error } = useCluster(name || null); + + if (loading) { + return ( + + + + + + ); + } + + if (error || !cluster) { + return ( + + + + {error || 'Cluster not found'} + + + + + ); + } + + return ( + + + + ); +} diff --git a/dashboard/src/components/ClusterListPage.tsx b/dashboard/src/components/ClusterListPage.tsx new file mode 100644 index 00000000..9144fbe8 --- /dev/null +++ b/dashboard/src/components/ClusterListPage.tsx @@ -0,0 +1,343 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Paper, + Chip, + Button, + IconButton, + TextField, + InputAdornment, + MenuItem, + Select, + FormControl, + InputLabel, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + CircularProgress, +} from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; +import { + Search as SearchIcon, + Refresh as RefreshIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, + Launch as LaunchIcon, +} from "@mui/icons-material"; +import { fetchClusters } from '../api/clusterService'; +import type { Cluster } from '../api/clusterService'; +import { useCluster } from '../hooks/useCluster'; +import ClusterDetailContent from './ClusterDetailContent'; +import DrawerLayout from './layout/DrawerLayout'; +import { fetchClusterAddons } from '../api/addonService'; + +// Gets the Hub Accepted status of a cluster +const getHubAcceptedStatus = (cluster: Cluster) => { + return cluster.hubAccepted ? 'Accepted' : 'Not Accepted'; +}; + +/** + * Page component for displaying a list of clusters with a detail drawer + */ +export default function ClusterListPage() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [clusters, setClusters] = useState([]); + const [loading, setLoading] = useState(true); + + // Get selected cluster from URL query parameter + const selectedClusterId = searchParams.get('selected'); + + // Find the selected cluster in the list + const selectedClusterData = clusters.find(c => c.id === selectedClusterId); + + // Use our custom hook to manage the selected cluster data + const { cluster: detailCluster } = useCluster( + selectedClusterId, + { initialData: selectedClusterData, skipFetch: !selectedClusterId } + ); + + // Function to load clusters data + const loadClusters = async () => { + try { + setLoading(true); + const clusterData = await fetchClusters(); + setClusters(clusterData); + setLoading(false); + } catch (error) { + console.error('Error fetching cluster list:', error); + setLoading(false); + } + }; + + // Load clusters on component mount + useEffect(() => { + loadClusters(); + }, []); + + // Fetch addon counts/names for all clusters after clusters are loaded + useEffect(() => { + if (!clusters.length) return; + let cancelled = false; + async function fetchAllAddons() { + const updated = await Promise.all(clusters.map(async (cluster) => { + try { + const addons = await fetchClusterAddons(cluster.name); + return { + ...cluster, + addonCount: addons.length, + addonNames: addons.map(a => a.name), + }; + } catch { + return { ...cluster, addonCount: 0, addonNames: [] }; + } + })); + if (!cancelled) { + setClusters(updated); + } + } + fetchAllAddons(); + return () => { cancelled = true; }; + }, [loading]); + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + const handleFilterStatusChange = (event: SelectChangeEvent) => { + setFilterStatus(event.target.value); + }; + + const handleClusterSelect = (clusterId: string) => { + // Update URL with selected cluster + setSearchParams({ selected: clusterId }); + }; + + const handleCloseDetail = () => { + // Remove selected parameter from URL + setSearchParams({}); + }; + + const handleViewFullDetails = () => { + if (selectedClusterId) { + navigate(`/clusters/${selectedClusterData?.name}`); + } + }; + + // Get status icon based on ManagedClusterConditionAvailable + const getStatusIcon = (status: string) => { + switch (status) { + case "Online": + return ; + case "Offline": + return ; + default: + return null; + } + }; + + // Filter clusters based on search term and status filter + const filteredClusters = clusters.filter(cluster => { + const matchesSearch = + cluster.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (cluster.labels?.region || '').toLowerCase().includes(searchTerm.toLowerCase()) || + // Search in clusterClaims + cluster.clusterClaims?.some(claim => + claim.name.toLowerCase().includes(searchTerm.toLowerCase()) || + claim.value.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const matchesStatus = filterStatus === 'all' || cluster.status === filterStatus; + return matchesSearch && matchesStatus; + }); + + return ( + + {/* Cluster list */} + + + Clusters + + + {/* Filters and search */} + + + + + + + ), + }} + /> + + + + Status + + + + + + + {loading ? : } + + + + + + + {/* Cluster Table */} + + + + + Name + Status + Version + Hub Accepted + Labels + Cluster Claims + + + Add-ons + + + Created + + + + {filteredClusters.map((cluster) => ( + td': { + padding: '12px 16px', + } + }} + onClick={() => handleClusterSelect(cluster.id)} + > + {cluster.name} + + + {getStatusIcon(cluster.status)} + {cluster.status} + + + {cluster.version || "-"} + {getHubAcceptedStatus(cluster)} + + {cluster.labels ? ( + + {Object.entries(cluster.labels).map(([key, value]) => ( + + ))} + + ) : ( + "-" + )} + + + {cluster.clusterClaims && cluster.clusterClaims.length > 0 ? ( + + {cluster.clusterClaims.map((claim) => ( + + ))} + + ) : ( + "-" + )} + + + {typeof cluster.addonCount === 'number' ? ( + 0 + ? cluster.addonNames.join(', ') + : 'No add-ons' + } + arrow + > + + {cluster.addonCount} + + + ) : ( + + )} + + + {cluster.creationTimestamp + ? new Date(cluster.creationTimestamp).toLocaleDateString() + : "-"} + + + ))} + +
+
+
+ + {/* Detail drawer */} + {selectedClusterId && detailCluster && ( + + + + + + + )} +
+ ); +} diff --git a/dashboard/src/components/ClusterManifestWorksList.tsx b/dashboard/src/components/ClusterManifestWorksList.tsx new file mode 100644 index 00000000..25d992f2 --- /dev/null +++ b/dashboard/src/components/ClusterManifestWorksList.tsx @@ -0,0 +1,221 @@ +import { + Box, + Typography, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Collapse, + IconButton, + Chip, + CircularProgress, + Alert, +} from '@mui/material'; +import type { ManifestWork } from '../api/manifestWorkService'; +import { useState } from 'react'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; + +interface ManifestWorkRowProps { + manifestWork: ManifestWork; +} + +// Format date function +const formatDate = (dateString?: string) => { + if (!dateString) return 'Unknown'; + return new Date(dateString).toLocaleString('en-US'); +}; + +// Get status of a ManifestWork based on conditions +const getManifestWorkStatus = (manifestWork: ManifestWork): { status: string; color: 'success' | 'error' | 'warning' | 'default' } => { + if (!manifestWork.conditions || manifestWork.conditions.length === 0) { + return { status: 'Unknown', color: 'default' }; + } + + const appliedCondition = manifestWork.conditions.find(c => c.type === 'Applied'); + if (appliedCondition) { + if (appliedCondition.status === 'True') { + return { status: 'Applied', color: 'success' }; + } + } + + const availableCondition = manifestWork.conditions.find(c => c.type === 'Available'); + if (availableCondition) { + if (availableCondition.status === 'True') { + return { status: 'Available', color: 'success' }; + } else { + return { status: 'Unavailable', color: 'error' }; + } + } + + return { status: 'Unknown', color: 'default' }; +}; + +// Component for a single ManifestWork row with expandable details +function ManifestWorkRow({ manifestWork }: ManifestWorkRowProps) { + const [open, setOpen] = useState(false); + const manifestWorkStatus = getManifestWorkStatus(manifestWork); + + return ( + <> + *': { borderBottom: 'unset' } }}> + + setOpen(!open)} + > + {open ? : } + + + + + {manifestWork.name} + + + + + + {formatDate(manifestWork.creationTimestamp)} + + + + + + + Manifests + + + + + + Kind + Name + Namespace + Status + + + + {manifestWork.resourceStatus?.manifests?.map((manifest, idx) => { + const appliedCondition = manifest.conditions.find(c => c.type === 'Applied'); + const status = appliedCondition?.status === 'True' ? 'Applied' : 'Not Applied'; + const statusColor = appliedCondition?.status === 'True' ? 'success' : 'error'; + + return ( + + {manifest.resourceMeta.kind || '-'} + {manifest.resourceMeta.name || '-'} + {manifest.resourceMeta.namespace || '-'} + + + + + ); + })} + +
+
+ + + Conditions + + + + + + Type + Status + Reason + Message + Last Updated + + + + {manifestWork.conditions?.map((condition, idx) => ( + + {condition.type} + + + + {condition.reason || '-'} + {condition.message || '-'} + {formatDate(condition.lastTransitionTime)} + + ))} + +
+
+
+
+
+
+ + ); +} + +interface ClusterManifestWorksListProps { + manifestWorks: ManifestWork[]; + loading: boolean; + error: string | null; +} + +export default function ClusterManifestWorksList({ manifestWorks, loading, error }: ClusterManifestWorksListProps) { + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + Error loading manifest works: {error} + + ); + } + + if (manifestWorks.length === 0) { + return ( + + No manifest works found for this cluster. + + ); + } + + return ( + + + + + + Name + Status + Created + + + + {manifestWorks.map((manifestWork) => ( + + ))} + +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/ClustersetList.tsx b/dashboard/src/components/ClustersetList.tsx new file mode 100644 index 00000000..1576d3c0 --- /dev/null +++ b/dashboard/src/components/ClustersetList.tsx @@ -0,0 +1,670 @@ +import { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { + Box, + Typography, + Paper, + IconButton, + TextField, + InputAdornment, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + alpha, + useTheme, + CircularProgress, + FormControl, + Select, + MenuItem, + InputLabel, + Chip, +} from "@mui/material"; +import type { SelectChangeEvent } from '@mui/material'; +import { + Search as SearchIcon, + Refresh as RefreshIcon, +} from "@mui/icons-material"; +import { fetchClusterSets, type ClusterSet } from '../api/clusterSetService'; +import { fetchClusters, type Cluster } from '../api/clusterService'; +import { fetchClusterSetBindings, type ManagedClusterSetBinding } from '../api/clusterSetBindingService'; +import DrawerLayout from './layout/DrawerLayout'; +import { useClusterSet } from '../hooks/useClusterSet'; + +// Define the ClusterSetCluster interface to represent clusters in a set +interface ClusterSetCluster { + id: string; + name: string; + status: string; +} + +const ClustersetList = () => { + const theme = useTheme(); + const [searchParams, setSearchParams] = useSearchParams(); + const [searchTerm, setSearchTerm] = useState(""); + const [selectorTypeFilter, setSelectorTypeFilter] = useState("all"); + const [clusterSets, setClusterSets] = useState([]); + const [clusters, setClusters] = useState([]); + const [bindings, setBindings] = useState([]); + const [loading, setLoading] = useState(true); + const [clustersLoading, setClustersLoading] = useState(true); + const [bindingsLoading, setBindingsLoading] = useState(true); + const [error, setError] = useState(null); + const [clusterSetCounts, setClusterSetCounts] = useState>({}); + const [bindingCounts, setBindingCounts] = useState>({}); + + // Get selected clusterset from URL query parameter + const selectedClusterSetName = searchParams.get('selected'); + + // Selected clusterset details + const [clusterSetClusters, setClusterSetClusters] = useState([]); + const [clusterSetBindings, setClusterSetBindings] = useState([]); + const [detailClustersLoading, setDetailClustersLoading] = useState(false); + const [detailBindingsLoading, setDetailBindingsLoading] = useState(false); + const [detailClustersError, setDetailClustersError] = useState(null); + const [detailBindingsError, setDetailBindingsError] = useState(null); + + // Use our custom hook to fetch and manage cluster set data + const { + clusterSet: selectedClusterSetData, + } = useClusterSet(selectedClusterSetName || null); + + useEffect(() => { + const loadClusterSets = async () => { + try { + setLoading(true); + setError(null); + const data = await fetchClusterSets(); + setClusterSets(data); + setLoading(false); + } catch (error) { + console.error('Error fetching cluster sets:', error); + setError('Failed to load cluster sets'); + setLoading(false); + } + }; + + loadClusterSets(); + }, []); + + useEffect(() => { + const loadClusters = async () => { + try { + setClustersLoading(true); + const data = await fetchClusters(); + setClusters(data); + setClustersLoading(false); + } catch (error) { + console.error('Error fetching clusters:', error); + setClustersLoading(false); + } + }; + + loadClusters(); + }, []); + + useEffect(() => { + const loadBindings = async () => { + try { + setBindingsLoading(true); + const data = await fetchClusterSetBindings(); + setBindings(data); + setBindingsLoading(false); + } catch (error) { + console.error('Error fetching cluster set bindings:', error); + setBindingsLoading(false); + } + }; + + loadBindings(); + }, []); + + // Calculate cluster counts for each cluster set + useEffect(() => { + if (clusters.length === 0 || clusterSets.length === 0 || loading || clustersLoading) return; + + const counts: Record = {}; + + clusterSets.forEach(clusterSet => { + // Get the selector type from the cluster set + const selectorType = clusterSet.spec?.clusterSelector?.selectorType || 'ExclusiveClusterSetLabel'; + let count = 0; + + // Filter clusters based on the selector type + switch (selectorType) { + case 'ExclusiveClusterSetLabel': + // Use the exclusive cluster set label to filter clusters + count = clusters.filter(cluster => + cluster.labels && + cluster.labels['cluster.open-cluster-management.io/clusterset'] === clusterSet.name + ).length; + break; + + case 'LabelSelector': { + // Use the label selector to filter clusters + const labelSelector = clusterSet.spec?.clusterSelector?.labelSelector; + + if (!labelSelector || Object.keys(labelSelector).length === 0) { + // If labelSelector is empty, select all clusters (labels.Everything()) + count = clusters.length; + } else { + // Filter clusters based on the label selector + count = clusters.filter(cluster => { + if (!cluster.labels) return false; + + // Check if all matchLabels are satisfied + for (const [key, value] of Object.entries(labelSelector)) { + if (typeof value === 'string' && cluster.labels[key] !== value) { + return false; + } + } + return true; + }).length; + } + } + break; + + default: + count = 0; + } + + counts[clusterSet.id] = count; + }); + + setClusterSetCounts(counts); + }, [clusters, clusterSets, loading, clustersLoading]); + + // Calculate binding counts for each cluster set + useEffect(() => { + if (bindings.length === 0 || clusterSets.length === 0 || loading || bindingsLoading) return; + + const counts: Record = {}; + + clusterSets.forEach(clusterSet => { + const count = bindings.filter(binding => binding.clusterSet === clusterSet.name).length; + counts[clusterSet.id] = count; + }); + + setBindingCounts(counts); + }, [bindings, clusterSets, loading, bindingsLoading]); + + // Load clusters that belong to selected cluster set + useEffect(() => { + const fetchClusterSetClusters = async () => { + if (!selectedClusterSetName || !selectedClusterSetData) return; + + try { + setDetailClustersLoading(true); + setDetailClustersError(null); + + // Fetch all clusters + const allClusters = await fetchClusters(); + let clustersInSet: Cluster[] = []; + + // Get the selector type from the cluster set + const selectorType = selectedClusterSetData.spec?.clusterSelector?.selectorType || 'ExclusiveClusterSetLabel'; + + // Filter clusters based on the selector type + switch (selectorType) { + case 'ExclusiveClusterSetLabel': + // Use the exclusive cluster set label to filter clusters + clustersInSet = allClusters.filter(cluster => + cluster.labels && + cluster.labels['cluster.open-cluster-management.io/clusterset'] === selectedClusterSetName + ); + break; + + case 'LabelSelector': { + // Use the label selector to filter clusters + const labelSelector = selectedClusterSetData.spec?.clusterSelector?.labelSelector; + + if (!labelSelector || Object.keys(labelSelector).length === 0) { + // If labelSelector is empty, select all clusters (labels.Everything()) + clustersInSet = allClusters; + } else { + // Filter clusters based on the label selector + clustersInSet = allClusters.filter(cluster => { + if (!cluster.labels) return false; + + // Check if all matchLabels are satisfied + for (const [key, value] of Object.entries(labelSelector)) { + if (typeof value === 'string' && cluster.labels[key] !== value) { + return false; + } + } + return true; + }); + } + } + break; + + default: + setDetailClustersError(`Unsupported selector type: ${selectorType}`); + setDetailClustersLoading(false); + return; + } + + // Map to the simplified ClusterSetCluster format + const mappedClusters = clustersInSet.map(cluster => ({ + id: cluster.id, + name: cluster.name, + status: cluster.status + })); + + setClusterSetClusters(mappedClusters); + setDetailClustersLoading(false); + } catch (error) { + console.error('Error fetching clusters for cluster set:', error); + setDetailClustersError('Failed to load clusters for this cluster set'); + setDetailClustersLoading(false); + } + }; + + if (selectedClusterSetName && selectedClusterSetData) { + fetchClusterSetClusters(); + } + }, [selectedClusterSetName, selectedClusterSetData]); + + // Load bindings for selected cluster set + useEffect(() => { + const fetchBindings = async () => { + if (!selectedClusterSetName) return; + + try { + setDetailBindingsLoading(true); + setDetailBindingsError(null); + + const filteredBindings = bindings.filter(binding => binding.clusterSet === selectedClusterSetName); + setClusterSetBindings(filteredBindings); + setDetailBindingsLoading(false); + } catch (error) { + console.error('Error preparing bindings for cluster set:', error); + setDetailBindingsError('Failed to load bindings for this cluster set'); + setDetailBindingsLoading(false); + } + }; + + fetchBindings(); + }, [selectedClusterSetName, bindings]); + + // Get unique selector types from cluster sets + const getSelectorTypes = () => { + const types = new Set(); + clusterSets.forEach(clusterSet => { + const selectorType = clusterSet.spec?.clusterSelector?.selectorType || 'Unknown'; + types.add(selectorType); + }); + return Array.from(types); + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + const handleSelectorTypeChange = (event: SelectChangeEvent) => { + setSelectorTypeFilter(event.target.value); + }; + + const handleClusterSetSelect = (name: string) => { + setSearchParams({ selected: name }); + }; + + const handleCloseDetail = () => { + // Remove selected parameter from URL + setSearchParams({}); + }; + + const handleRefresh = () => { + const loadData = async () => { + try { + setLoading(true); + setClustersLoading(true); + setBindingsLoading(true); + setError(null); + + const [clusterSetsData, clustersData, bindingsData] = await Promise.all([ + fetchClusterSets(), + fetchClusters(), + fetchClusterSetBindings() + ]); + + setClusterSets(clusterSetsData); + setClusters(clustersData); + setBindings(bindingsData); + + setLoading(false); + setClustersLoading(false); + setBindingsLoading(false); + } catch (error) { + console.error('Error refreshing data:', error); + setError('Failed to refresh data'); + setLoading(false); + setClustersLoading(false); + setBindingsLoading(false); + } + }; + + loadData(); + }; + + // Format date string + const formatDate = (dateString?: string) => { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleDateString(); + }; + + // Format detailed date string + const formatDetailDate = (dateString?: string) => { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleDateString() + " " + date.toLocaleTimeString(); + }; + + // Filter cluster sets based on search term and selector type + const filteredClusterSets = clusterSets.filter( + (clusterSet) => { + const nameMatches = clusterSet.name.toLowerCase().includes(searchTerm.toLowerCase()); + const selectorType = clusterSet.spec?.clusterSelector?.selectorType || 'Unknown'; + const selectorMatches = selectorTypeFilter === 'all' || selectorType === selectorTypeFilter; + + return nameMatches && selectorMatches; + } + ); + + return ( + + {/* Cluster set list */} + + + ClusterSets + + + + + + + + + ), + }} + /> + + + + Selector Type + + + + + + + + + + + + + + {error && ( + + {error} + + )} + + {loading || clustersLoading || bindingsLoading ? ( + + + + ) : ( + + + + + Name + Clusters + Bindings + Selector Type + Created + + + + {filteredClusterSets.length === 0 ? ( + + + + No cluster sets found + + + + ) : ( + filteredClusterSets.map((clusterSet) => ( + handleClusterSetSelect(clusterSet.name)} + hover + selected={selectedClusterSetName === clusterSet.name} + sx={{ + cursor: "pointer", + py: 1.5, + '& > td': { + padding: '12px 16px', + }, + "&:hover": { + bgcolor: alpha(theme.palette.primary.main, 0.05), + }, + }} + > + + + {clusterSet.name} + + + + {clusterSetCounts[clusterSet.id] || 0} + + + {bindingCounts[clusterSet.id] || 0} + + + {clusterSet.spec?.clusterSelector?.selectorType || "N/A"} + + {formatDate(clusterSet.creationTimestamp)} + + )) + )} + +
+
+ )} +
+ + {/* Detail drawer */} + {selectedClusterSetName && selectedClusterSetData && ( + + {/* ClusterSet Overview */} + + Overview + + + + Cluster Count + {clusterSetClusters.length} + + + Binding Count + {clusterSetBindings.length} + + + Created + {formatDetailDate(selectedClusterSetData.creationTimestamp)} + + + Selector Type + {selectedClusterSetData.spec?.clusterSelector?.selectorType || "N/A"} + + + + + {/* Clusters */} + + Clusters + + + + + + Name + Status + + + + {detailClustersError ? ( + + + Error loading clusters: {detailClustersError} + + + ) : detailClustersLoading ? ( + + + + Loading clusters... + + + ) : clusterSetClusters.length === 0 ? ( + + No clusters in this set + + ) : ( + clusterSetClusters.map((cluster) => ( + + + + {cluster.name} + + + + + + + )) + )} + +
+
+
+ + {/* Bindings */} + + Bindings + + + + + + Name + Namespace + Status + + + + {detailBindingsError ? ( + + + Error loading bindings: {detailBindingsError} + + + ) : detailBindingsLoading ? ( + + + + Loading bindings... + + + ) : clusterSetBindings.length === 0 ? ( + + No bindings for this cluster set + + ) : ( + clusterSetBindings.map((binding) => ( + + + + {binding.name} + + + {binding.namespace} + + + + + )) + )} + +
+
+
+
+ )} +
+ ); +}; + +export default ClustersetList; \ No newline at end of file diff --git a/dashboard/src/components/Login.tsx b/dashboard/src/components/Login.tsx new file mode 100644 index 00000000..fdffcf0a --- /dev/null +++ b/dashboard/src/components/Login.tsx @@ -0,0 +1,231 @@ +import { useState } from 'react'; +import { useAuth } from '../auth/AuthContext'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Card, + CardHeader, + CardContent, + CardActions, + TextField, + Button, + Typography, + useTheme, + Alert, + CircularProgress, + Accordion, + AccordionSummary, + AccordionDetails, + Stack, +} from "@mui/material"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +const Login = () => { + const [token, setToken] = useState(''); + const [error, setError] = useState(null); + const [testing, setTesting] = useState(false); + const { login, isLoading, error: authError } = useAuth(); + const navigate = useNavigate(); + const theme = useTheme(); + + const handleSubmit = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + + if (!token.trim()) { + setError('Token is required'); + return; + } + + // Basic format validation + if (token.trim().length < 10) { + setError('Token seems too short'); + return; + } + + setError(null); + setTesting(true); + + try { + // Test the token by making a test API call + const testToken = token.startsWith('Bearer ') ? token : `Bearer ${token}`; + const response = await fetch('/api/clusters', { + headers: { + 'Authorization': testToken, + 'Content-Type': 'application/json' + } + }); + + if (response.ok) { + // Token works, proceed with login + login(testToken); + navigate('/overview'); + } else if (response.status === 401) { + setError('Invalid or expired token. Please check your token and try again.'); + } else { + setError(`Authentication failed: ${response.status} ${response.statusText}`); + } + } catch (err) { + setError('Failed to test token. Please check your connection and try again.'); + } finally { + setTesting(false); + } + }; + + const getTokenInstructions = () => ( + + + To get a service account token for OCM Dashboard: + + + +{`# Create a service account (if not exists) +kubectl create serviceaccount dashboard-user -n default + +# Create cluster role binding +kubectl create clusterrolebinding dashboard-user \\ + --clusterrole=cluster-admin \\ + --serviceaccount=default:dashboard-user + +# Get the token +kubectl create token dashboard-user --duration=24h`} + + + + Copy the output token and paste it in the field above. + + + ); + + return ( + theme.palette.background.default, + p: 2, + }} + > + + + + + OCM Dashboard + + + } + subheader={ + + Sign in with your Kubernetes Bearer Token + + } + /> + + {(error || authError) && ( + + {error || authError} + + )} + + {isLoading && ( + + + + Loading authentication... + + + )} + +
+ + setToken(e.target.value)} + error={!!error} + slotProps={{ + input: { + style: { + fontFamily: "JetBrains Mono, monospace", + fontSize: "0.75rem", + } + } + }} + sx={{ mb: 2 }} + disabled={testing} + /> + + + } + aria-controls="token-instructions-content" + id="token-instructions-header" + > + + How to get a Kubernetes token? + + + + {getTokenInstructions()} + + + + + + + +
+ + + + Token is stored locally in your browser only. + + + +
+ + ); +}; + +export default Login; diff --git a/dashboard/src/components/OverviewPage.tsx b/dashboard/src/components/OverviewPage.tsx new file mode 100644 index 00000000..20f5fe93 --- /dev/null +++ b/dashboard/src/components/OverviewPage.tsx @@ -0,0 +1,383 @@ +import { Box, Typography, Paper, Grid, alpha, useTheme } from "@mui/material" +import { Storage as StorageIcon, Layers as LayersIcon, DeviceHub as DeviceHubIcon } from "@mui/icons-material" +import { useEffect, useState } from "react" +import { fetchClusters } from "../api/clusterService" +import { fetchClusterSets } from "../api/clusterSetService" +import { fetchPlacements } from "../api/placementService" +import type { Cluster } from "../api/clusterService" +import type { ClusterSet } from "../api/clusterSetService" +import type { Placement } from "../api/placementService" + +export default function OverviewPage() { + const theme = useTheme() + const [clusters, setClusters] = useState([]) + const [clusterSets, setClusterSets] = useState([]) + const [placements, setPlacements] = useState([]) + const [loading, setLoading] = useState(true) + const [clusterSetsLoading, setClusterSetsLoading] = useState(true) + const [placementsLoading, setPlacementsLoading] = useState(true) + const [clusterSetCounts, setClusterSetCounts] = useState>({}) + + useEffect(() => { + const loadClusters = async () => { + setLoading(true) + try { + const data = await fetchClusters() + setClusters(data) + } finally { + setLoading(false) + } + } + loadClusters() + }, []) + + useEffect(() => { + const loadClusterSets = async () => { + setClusterSetsLoading(true) + try { + const data = await fetchClusterSets() + setClusterSets(data) + } finally { + setClusterSetsLoading(false) + } + } + loadClusterSets() + }, []) + + useEffect(() => { + const loadPlacements = async () => { + setPlacementsLoading(true) + try { + const data = await fetchPlacements() + setPlacements(data) + } finally { + setPlacementsLoading(false) + } + } + loadPlacements() + }, []) + + // Calculate cluster counts for each cluster set + useEffect(() => { + if (clusters.length === 0 || clusterSets.length === 0) return; + + const counts: Record = {}; + + clusterSets.forEach(clusterSet => { + // Get the selector type from the cluster set + const selectorType = clusterSet.spec?.clusterSelector?.selectorType || 'ExclusiveClusterSetLabel'; + let count = 0; + + // Filter clusters based on the selector type + switch (selectorType) { + case 'ExclusiveClusterSetLabel': + // Use the exclusive cluster set label to filter clusters + count = clusters.filter(cluster => + cluster.labels && + cluster.labels['cluster.open-cluster-management.io/clusterset'] === clusterSet.name + ).length; + break; + + case 'LabelSelector': { + // Use the label selector to filter clusters + const labelSelector = clusterSet.spec?.clusterSelector?.labelSelector; + + if (!labelSelector || Object.keys(labelSelector).length === 0) { + // If labelSelector is empty, select all clusters (labels.Everything()) + count = clusters.length; + } else { + // Filter clusters based on the label selector + count = clusters.filter(cluster => { + if (!cluster.labels) return false; + + // Check if all matchLabels are satisfied + for (const [key, value] of Object.entries(labelSelector)) { + if (typeof value === 'string' && cluster.labels[key] !== value) { + return false; + } + } + return true; + }).length; + } + } + break; + + default: + count = 0; + } + + counts[clusterSet.id] = count; + }); + + setClusterSetCounts(counts); + }, [clusters, clusterSets]); + + // Calculate stats from real data + const total = clusters.length + // 只使用"Online"状态作为可用集群的判断标准 + const available = clusters.filter(c => c.status === "Online").length + const totalClusterSets = clusterSets.length + const totalPlacements = placements.length + const successfulPlacements = placements.filter(p => p.succeeded).length + + return ( + + + Overview + + + {/* Simplified KPI cards */} + + {/* Combined Clusters card */} + + + + + + + + + + All Clusters + + + {loading ? "-" : total} + + + + + Available + + + {loading ? "-" : available} + + + + + + + Availability Rate + + + + + 0 ? `${(available / total) * 100}%` : 0, + bgcolor: "success.main", + borderRadius: 4, + }} + /> + + + {loading || total === 0 ? '-' : Math.round((available / total) * 100)}% + + + + + + {loading ? '-' : total - available} clusters currently unavailable + + + + + + {/* ManagedClusterSets card */} + + + + + + + + + ManagedClusterSets + + + {clusterSetsLoading ? "-" : totalClusterSets} + + + + + + Cluster distribution + + + {!clusterSetsLoading && clusterSets.length > 0 && ( + + {clusterSets.slice(0, 3).map((set) => ( + + + {set.name} + + + {clusterSetCounts[set.id] || 0} clusters + + + ))} + {clusterSets.length > 3 && ( + + + {clusterSets.length - 3} more sets + + )} + + )} + + {clusterSetsLoading && ( + + + Loading cluster sets... + + + )} + + {!clusterSetsLoading && clusterSets.length === 0 && ( + + + No cluster sets found + + + )} + + + + {/* Placements card */} + + + + + + + + + + All Placements + + + {placementsLoading ? "-" : totalPlacements} + + + + + Successful + + + {placementsLoading ? "-" : successfulPlacements} + + + + + + + Success Rate + + + + + 0 ? `${(successfulPlacements / totalPlacements) * 100}%` : 0, + bgcolor: "success.main", + borderRadius: 4, + }} + /> + + + {placementsLoading || totalPlacements === 0 ? '-' : Math.round((successfulPlacements / totalPlacements) * 100)}% + + + + + + {placementsLoading ? '-' : totalPlacements - successfulPlacements} placements currently pending or failed + + + + + + + ) +} \ No newline at end of file diff --git a/dashboard/src/components/PlacementListPage.tsx b/dashboard/src/components/PlacementListPage.tsx new file mode 100644 index 00000000..d912fb85 --- /dev/null +++ b/dashboard/src/components/PlacementListPage.tsx @@ -0,0 +1,489 @@ +import { useState, useEffect, useMemo } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { + Box, + Typography, + Paper, + Chip, + IconButton, + TextField, + InputAdornment, + MenuItem, + Select, + FormControl, + InputLabel, + Grid, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + CircularProgress, +} from "@mui/material"; +import type { SelectChangeEvent } from "@mui/material"; +import { + Search as SearchIcon, + Refresh as RefreshIcon, + CheckCircle as CheckCircleIcon, + Error as ErrorIcon, +} from "@mui/icons-material"; +import { fetchPlacements, fetchPlacementDecisions } from '../api/placementService'; +import type { Placement, PlacementDecision } from '../api/placementService'; +import DrawerLayout from './layout/DrawerLayout'; + +/** + * Page component for displaying a list of placements + */ +export default function PlacementListPage() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + + const [searchTerm, setSearchTerm] = useState(""); + const [filterStatus, setFilterStatus] = useState("all"); + const [filterNamespace, setFilterNamespace] = useState("all"); + const [placements, setPlacements] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingDecisions, setLoadingDecisions] = useState(false); + const [placementDecisions, setPlacementDecisions] = useState([]); + + // Get selected placement from URL query parameter + const selectedPlacementId = searchParams.get('selected'); + + // Find the selected placement in the list + const selectedPlacementData = placements.find(p => p.id === selectedPlacementId); + + // Get unique namespaces from placements + const uniqueNamespaces = useMemo(() => { + const namespaces = new Set(); + placements.forEach(placement => { + if (placement.namespace) { + namespaces.add(placement.namespace); + } + }); + return Array.from(namespaces).sort(); + }, [placements]); + + // Load placements on component mount + useEffect(() => { + const loadPlacements = async () => { + try { + setLoading(true); + const placementData = await fetchPlacements(); + setPlacements(placementData); + setLoading(false); + } catch (error) { + console.error('Error fetching placement list:', error); + setLoading(false); + } + }; + + loadPlacements(); + }, []); + + // Load placement decisions for a selected placement + useEffect(() => { + const loadPlacementDecisions = async () => { + if (selectedPlacementData) { + try { + setLoadingDecisions(true); + const decisions = await fetchPlacementDecisions( + selectedPlacementData.namespace, + selectedPlacementData.name + ); + setPlacementDecisions(decisions); + setLoadingDecisions(false); + } catch (error) { + console.error('Error fetching placement decisions:', error); + setLoadingDecisions(false); + } + } else { + // If no placement is selected, clear decisions + setPlacementDecisions([]); + } + }; + + // Load decisions for both the Clusters tab and the Decisions tab + if (selectedPlacementData) { + loadPlacementDecisions(); + } + }, [selectedPlacementData]); + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + const handleFilterStatusChange = (event: SelectChangeEvent) => { + setFilterStatus(event.target.value); + }; + + const handleFilterNamespaceChange = (event: SelectChangeEvent) => { + setFilterNamespace(event.target.value); + }; + + const handlePlacementSelect = (placementId: string) => { + // Update URL with selected placement + setSearchParams({ selected: placementId }); + }; + + const handleCloseDetail = () => { + // Remove selected parameter from URL + setSearchParams({}); + }; + + // Get status icon based on succeeded condition + const getStatusIcon = (succeeded: boolean) => { + return succeeded ? ( + + ) : ( + + ); + }; + + // Filter placements based on search term, status filter, and namespace filter + const filteredPlacements = placements.filter(placement => { + const matchesSearch = + placement.name.toLowerCase().includes(searchTerm.toLowerCase()) || + placement.namespace.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = + filterStatus === 'all' || + (filterStatus === 'succeeded' && placement.succeeded) || + (filterStatus === 'not-succeeded' && !placement.succeeded); + + const matchesNamespace = + filterNamespace === 'all' || + placement.namespace === filterNamespace; + + return matchesSearch && matchesStatus && matchesNamespace; + }); + + const placementStatusText = (placement: Placement) => { + if (placement.succeeded) { + return "Succeeded"; + } else { + return placement.reasonMessage || "Not Succeeded"; + } + }; + + return ( + + {/* Placement list */} + + + Placements + + + {/* Filters and search */} + + + + + + + ), + }} + /> + + + + Status + + + + + + Namespace + + + + + + window.location.reload()}> + + + + + + + + {/* Placement Table */} + {loading ? ( + + + + ) : ( + + + + + Name + Namespace + Status + Requested + Selected + ClusterSets + Created + + + + {filteredPlacements.length === 0 ? ( + + + + No placements found + + + + ) : ( + filteredPlacements.map((placement) => ( + td': { + padding: '12px 16px', + } + }} + onClick={() => handlePlacementSelect(placement.id)} + > + {placement.name} + {placement.namespace} + + + {getStatusIcon(!!placement.succeeded)} + + {placementStatusText(placement)} + + + + + {placement.numberOfClusters ?? "All"} + + + {placement.numberOfSelectedClusters} + + + + {placement.clusterSets && placement.clusterSets.length > 0 ? ( + placement.clusterSets.map((set) => ( + + )) + ) : ( + - + )} + + + + {placement.creationTimestamp + ? new Date(placement.creationTimestamp).toLocaleDateString() + : "-"} + + + )) + )} + +
+
+ )} +
+ + {/* Detail drawer */} + {selectedPlacementId && selectedPlacementData && ( + + + + {/* Basic Placement Info */} + + + Overview + + + + Status: + + {getStatusIcon(!!selectedPlacementData.succeeded)} + + {placementStatusText(selectedPlacementData)} + + + + + + Requested Clusters: + {selectedPlacementData.numberOfClusters ?? "All matching"} + + + + Selected Clusters: + {selectedPlacementData.numberOfSelectedClusters} + + + + ClusterSets: + + {selectedPlacementData.clusterSets && selectedPlacementData.clusterSets.length > 0 ? ( + selectedPlacementData.clusterSets.map((set) => ( + + )) + ) : ( + None + )} + + + + + Created: + + {selectedPlacementData.creationTimestamp + ? new Date(selectedPlacementData.creationTimestamp).toLocaleString() + : "-"} + + + + + {/* Decision Groups */} + {selectedPlacementData.decisionGroups && selectedPlacementData.decisionGroups.length > 0 && ( + + + Clusters + + + {loadingDecisions ? ( + + + + ) : ( + + + + + Name + Group Index + Group Name + + + + {selectedPlacementData.decisionGroups.flatMap((group) => { + // For each decision group + const rows: React.ReactNode[] = []; + + // Find the corresponding placement decisions for this group + const groupDecisions = placementDecisions.filter(decision => + group.decisions.includes(decision.name) + ); + + // Extract cluster names from each placement decision's status + groupDecisions.forEach(decision => { + if (decision.decisions) { + decision.decisions.forEach((decisionStatus: {clusterName: string; reason: string}) => { + if (decisionStatus.clusterName) { + rows.push( + + {decisionStatus.clusterName} + {group.decisionGroupIndex} + {group.decisionGroupName || "(default)"} + + ); + } + }); + } + }); + + return rows; + })} + +
+
+ )} +
+ )} + + {/* Selected Clusters */} + {selectedPlacementData.selectedClusters && selectedPlacementData.selectedClusters.length > 0 && ( + + + Selected Clusters + + + + + + + Cluster Name + Status + + + + {selectedPlacementData.selectedClusters.map((cluster) => ( + navigate(`/clusters/${cluster.name}`)} + > + {cluster.name} + + + {cluster.status === "Online" ? ( + + ) : ( + + )} + {cluster.status} + + + + ))} + +
+
+
+ )} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/dashboard/src/components/layout/AppBar.tsx b/dashboard/src/components/layout/AppBar.tsx new file mode 100644 index 00000000..5caf128a --- /dev/null +++ b/dashboard/src/components/layout/AppBar.tsx @@ -0,0 +1,135 @@ +import { + AppBar as MuiAppBar, + Toolbar, + IconButton, + Typography, + Box, + MenuItem, + Tooltip, + useTheme, + styled, + Menu +} from '@mui/material'; +import MenuIcon from '@mui/icons-material/Menu'; +import SettingsIcon from '@mui/icons-material/Settings'; +import { useState } from 'react'; +import { useAuth } from '../../auth/AuthContext'; +import { useNavigate } from 'react-router-dom'; + +interface AppBarProps { + open: boolean; + drawerWidth: number; + onDrawerToggle: () => void; +} + +const AppBarStyled = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== 'open' && prop !== 'drawerWidth', +})<{ + open: boolean; + drawerWidth: number; +}>(({ theme, open, drawerWidth }) => ({ + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(['width', 'margin'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + ...(open && { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(['width', 'margin'], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + }), +})); + +export default function AppBar({ open, drawerWidth, onDrawerToggle }: AppBarProps) { + const theme = useTheme(); + const [anchorEl, setAnchorEl] = useState(null); + const { logout } = useAuth(); + const navigate = useNavigate(); + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleSignOut = () => { + handleClose(); + logout(); + navigate('/login'); + }; + + return ( + + + + + + + {/* Logo */} + + OCM Logo + + OCM Dashboard + + + + + + {/* User menu */} + + + + + + + + Sign out + + + + + ); +} \ No newline at end of file diff --git a/dashboard/src/components/layout/AppShell.tsx b/dashboard/src/components/layout/AppShell.tsx new file mode 100644 index 00000000..2c1ae094 --- /dev/null +++ b/dashboard/src/components/layout/AppShell.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import { Box, CssBaseline } from '@mui/material'; +import { Outlet } from 'react-router-dom'; +import AppBar from './AppBar.tsx'; +import Drawer from './Drawer.tsx'; + +// Drawer width constant +const DRAWER_WIDTH = 240; + +export default function AppShell() { + const [open, setOpen] = useState(true); + + const toggleDrawer = () => { + setOpen(!open); + }; + + return ( + + + + {/* Top app bar */} + + + {/* Side navigation drawer */} + + + {/* Main content */} + theme.transitions.create('margin', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + height: '100%', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + pt: '64px', // AppBar height + backgroundColor: theme => + theme.palette.mode === 'light' + ? '#f5f5f9' + : theme.palette.background.default, + marginLeft: '0 !important', + boxSizing: 'border-box', + }} + > + + + + + + ); +} \ No newline at end of file diff --git a/dashboard/src/components/layout/Drawer.tsx b/dashboard/src/components/layout/Drawer.tsx new file mode 100644 index 00000000..ad3b2ec9 --- /dev/null +++ b/dashboard/src/components/layout/Drawer.tsx @@ -0,0 +1,149 @@ +import { + Drawer as MuiDrawer, + Toolbar, + Divider, + List, + ListItem, + ListItemButton, + ListItemIcon, + ListItemText, + IconButton, + styled +} from '@mui/material'; +import type { Theme } from '@mui/material'; +import { useLocation, useNavigate } from 'react-router-dom'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; +import DashboardIcon from '@mui/icons-material/Dashboard'; +import StorageIcon from '@mui/icons-material/Storage'; +import DeviceHubIcon from '@mui/icons-material/DeviceHub'; +import LayersIcon from '@mui/icons-material/Layers'; + +interface DrawerProps { + open: boolean; + drawerWidth: number; + onDrawerToggle: () => void; +} + +const DrawerStyled = styled(MuiDrawer, { + shouldForwardProp: (prop) => prop !== 'open' && prop !== 'drawerWidth', +})<{ + open: boolean; + drawerWidth: number; +}>(({ theme, open, drawerWidth }) => ({ + '& .MuiDrawer-paper': { + position: 'relative', + whiteSpace: 'nowrap', + width: drawerWidth, + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + boxSizing: 'border-box', + ...(!open && { + overflowX: 'hidden', + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + width: theme.spacing(7), + [theme.breakpoints.up('sm')]: { + width: theme.spacing(9), + }, + }), + }, +})); + +// Navigation items with paths +const navItems = [ + { text: 'Overview', icon: , path: '/overview' }, + { text: 'Clusters', icon: , path: '/clusters' }, + { text: 'Clustersets', icon: , path: '/clustersets' }, + { text: 'Placements', icon: , path: '/placements' }, +]; + +export default function Drawer({ open, drawerWidth, onDrawerToggle }: DrawerProps) { + const location = useLocation(); + const navigate = useNavigate(); + const currentPath = location.pathname; + + const handleNavigation = (path: string) => { + navigate(path); + }; + + return ( + theme.spacing(7), + [`@media (min-width: ${(theme: Theme) => theme.breakpoints.values.sm}px)`]: { + width: (theme: Theme) => theme.spacing(9), + }, + }), + }} + > + + + + + + + + {navItems.map((item) => { + const isActive = currentPath === item.path || + (item.path !== '/overview' && currentPath.startsWith(item.path)); + + return ( + + handleNavigation(item.path)} + sx={{ + minHeight: 48, + justifyContent: open ? 'initial' : 'center', + px: 2.5, + }} + > + + {item.icon} + + + + + ); + })} + + + ); +} diff --git a/dashboard/src/components/layout/DrawerLayout.tsx b/dashboard/src/components/layout/DrawerLayout.tsx new file mode 100644 index 00000000..130a4b65 --- /dev/null +++ b/dashboard/src/components/layout/DrawerLayout.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; +import { Box, IconButton, Typography } from '@mui/material'; +import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; + +interface DrawerLayoutProps { + children: ReactNode; + title: string; + icon?: ReactNode; + onClose?: () => void; +} + +/** + * Layout component for displaying content in a drawer + */ +export default function DrawerLayout({ children, title, icon, onClose }: DrawerLayoutProps) { + return ( + + + + {icon} + + {title} + + + {onClose && ( + + + + )} + + + {children} + + ); +} diff --git a/dashboard/src/components/layout/PageLayout.tsx b/dashboard/src/components/layout/PageLayout.tsx new file mode 100644 index 00000000..bfa7d535 --- /dev/null +++ b/dashboard/src/components/layout/PageLayout.tsx @@ -0,0 +1,61 @@ +import type { ReactNode } from 'react'; +import { Box, Typography, Paper, Button } from '@mui/material'; +import { Link } from 'react-router-dom'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; + +interface PageLayoutProps { + children: ReactNode; + title: string; + backLink?: string; + backLabel?: string; + actions?: ReactNode; +} + +/** + * Layout component for displaying content in a full page + */ +export default function PageLayout({ + children, + title, + backLink = '/clusters', + backLabel = 'Back to Clusters', + actions +}: PageLayoutProps) { + return ( + + + + {backLink && ( + + )} + + {title} + + + {actions && ( + + {actions} + + )} + + + (theme.palette.mode === 'light' ? 'white' : 'background.paper'), + }} + > + {children} + + + ); +} diff --git a/dashboard/src/hooks/useCluster.ts b/dashboard/src/hooks/useCluster.ts new file mode 100644 index 00000000..77c1f2b7 --- /dev/null +++ b/dashboard/src/hooks/useCluster.ts @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import { fetchClusterByName, type Cluster } from '../api/clusterService'; + +interface UseClusterOptions { + initialData?: Cluster | null; + skipFetch?: boolean; +} + +/** + * Custom hook for fetching and managing cluster data + * @param name - Cluster name to fetch + * @param options - Hook options + * @returns Object containing cluster data, loading state, error, and refetch function + */ +export function useCluster(name: string | null, options: UseClusterOptions = {}) { + const { initialData = null, skipFetch = false } = options; + + const [cluster, setCluster] = useState(initialData); + const [loading, setLoading] = useState(!initialData && !!name && !skipFetch); + const [error, setError] = useState(null); + + const fetchCluster = async (clusterName: string) => { + if (!clusterName) { + setError('Cluster name is required'); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await fetchClusterByName(clusterName); + setCluster(data); + setLoading(false); + } catch (err) { + console.error('Error fetching cluster details:', err); + setError('Unable to load cluster details'); + setLoading(false); + } + }; + + // Refetch function that can be called manually + const refetch = () => { + if (name) { + fetchCluster(name); + } + }; + + // Fetch data when name changes or when initialData is not provided + useEffect(() => { + if (name && !skipFetch && !initialData) { + fetchCluster(name); + } else if (initialData) { + setCluster(initialData); + setLoading(false); + setError(null); + } else if (!name) { + setCluster(null); + setLoading(false); + setError(null); + } + }, [name, initialData, skipFetch]); + + return { + cluster, + loading, + error, + refetch, + setCluster // Expose setter for cases where we need to update the cluster data + }; +} diff --git a/dashboard/src/hooks/useClusterAddons.ts b/dashboard/src/hooks/useClusterAddons.ts new file mode 100644 index 00000000..a1aad6e6 --- /dev/null +++ b/dashboard/src/hooks/useClusterAddons.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; +import { fetchClusterAddons } from '../api/addonService'; +import type { ManagedClusterAddon } from '../api/addonService'; + +/** + * Custom hook for fetching and managing cluster addons + * @param clusterName The name of the cluster to fetch addons for + * @returns Object containing addons, loading state, and error + */ +export const useClusterAddons = (clusterName: string | null) => { + const [addons, setAddons] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!clusterName) { + setLoading(false); + setAddons([]); + return; + } + + const loadAddons = async () => { + try { + setLoading(true); + setError(null); + const data = await fetchClusterAddons(clusterName); + setAddons(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + console.error('Error fetching cluster addons:', err); + } finally { + setLoading(false); + } + }; + + loadAddons(); + }, [clusterName]); + + return { addons, loading, error }; +}; \ No newline at end of file diff --git a/dashboard/src/hooks/useClusterManifestWorks.ts b/dashboard/src/hooks/useClusterManifestWorks.ts new file mode 100644 index 00000000..f84a370a --- /dev/null +++ b/dashboard/src/hooks/useClusterManifestWorks.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; +import { fetchManifestWorks } from '../api/manifestWorkService'; +import type { ManifestWork } from '../api/manifestWorkService'; + +/** + * Custom hook for fetching and managing cluster manifest works + * @param clusterName The name of the cluster to fetch manifest works for (used as namespace) + * @returns Object containing manifest works, loading state, and error + */ +export const useClusterManifestWorks = (clusterName: string | null) => { + const [manifestWorks, setManifestWorks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!clusterName) { + setLoading(false); + setManifestWorks([]); + return; + } + + const loadManifestWorks = async () => { + try { + setLoading(true); + setError(null); + const data = await fetchManifestWorks(clusterName); + setManifestWorks(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'An unknown error occurred'); + console.error('Error fetching cluster manifest works:', err); + } finally { + setLoading(false); + } + }; + + loadManifestWorks(); + }, [clusterName]); + + return { manifestWorks, loading, error }; +}; \ No newline at end of file diff --git a/dashboard/src/hooks/useClusterSet.ts b/dashboard/src/hooks/useClusterSet.ts new file mode 100644 index 00000000..6a5ce4de --- /dev/null +++ b/dashboard/src/hooks/useClusterSet.ts @@ -0,0 +1,71 @@ +import { useState, useEffect } from 'react'; +import { fetchClusterSetByName, type ClusterSet } from '../api/clusterSetService'; + +interface UseClusterSetOptions { + initialData?: ClusterSet | null; + skipFetch?: boolean; +} + +/** + * Custom hook for fetching and managing cluster set data + * @param name - Cluster set name to fetch + * @param options - Hook options + * @returns Object containing cluster set data, loading state, error, and refetch function + */ +export function useClusterSet(name: string | null, options: UseClusterSetOptions = {}) { + const { initialData = null, skipFetch = false } = options; + + const [clusterSet, setClusterSet] = useState(initialData); + const [loading, setLoading] = useState(!initialData && !!name && !skipFetch); + const [error, setError] = useState(null); + + const fetchClusterSetData = async (clusterSetName: string) => { + if (!clusterSetName) { + setError('Cluster set name is required'); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await fetchClusterSetByName(clusterSetName); + setClusterSet(data); + setLoading(false); + } catch (err) { + console.error('Error fetching cluster set details:', err); + setError('Unable to load cluster set details'); + setLoading(false); + } + }; + + // Refetch function that can be called manually + const refetch = () => { + if (name) { + fetchClusterSetData(name); + } + }; + + // Fetch data when name changes or when initialData is not provided + useEffect(() => { + if (name && !skipFetch && !initialData) { + fetchClusterSetData(name); + } else if (initialData) { + setClusterSet(initialData); + setLoading(false); + setError(null); + } else if (!name) { + setClusterSet(null); + setLoading(false); + setError(null); + } + }, [name, initialData, skipFetch]); + + return { + clusterSet, + loading, + error, + refetch, + setClusterSet // Expose setter for cases where we need to update the cluster set data + }; +} diff --git a/dashboard/src/hooks/usePlacement.ts b/dashboard/src/hooks/usePlacement.ts new file mode 100644 index 00000000..12fbc7c0 --- /dev/null +++ b/dashboard/src/hooks/usePlacement.ts @@ -0,0 +1,72 @@ +import { useState, useEffect } from 'react'; +import { fetchPlacementByName, type Placement } from '../api/placementService'; + +interface UsePlacementOptions { + initialData?: Placement | null; + skipFetch?: boolean; +} + +/** + * Custom hook for fetching and managing placement data + * @param namespace - Placement namespace to fetch + * @param name - Placement name to fetch + * @param options - Hook options + * @returns Object containing placement data, loading state, error, and refetch function + */ +export function usePlacement(namespace: string | null, name: string | null, options: UsePlacementOptions = {}) { + const { initialData = null, skipFetch = false } = options; + + const [placement, setPlacement] = useState(initialData); + const [loading, setLoading] = useState(!initialData && !!namespace && !!name && !skipFetch); + const [error, setError] = useState(null); + + const fetchPlacement = async (ns: string, placementName: string) => { + if (!ns || !placementName) { + setError('Namespace and placement name are required'); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const data = await fetchPlacementByName(ns, placementName); + setPlacement(data); + setLoading(false); + } catch (err) { + console.error('Error fetching placement details:', err); + setError('Unable to load placement details'); + setLoading(false); + } + }; + + // Refetch function that can be called manually + const refetch = () => { + if (namespace && name) { + fetchPlacement(namespace, name); + } + }; + + // Fetch data when namespace/name changes or when initialData is not provided + useEffect(() => { + if (namespace && name && !skipFetch && !initialData) { + fetchPlacement(namespace, name); + } else if (initialData) { + setPlacement(initialData); + setLoading(false); + setError(null); + } else if (!namespace || !name) { + setPlacement(null); + setLoading(false); + setError(null); + } + }, [namespace, name, initialData, skipFetch]); + + return { + placement, + loading, + error, + refetch, + setPlacement // Expose setter for cases where we need to update the placement data + }; +} \ No newline at end of file diff --git a/dashboard/src/index.css b/dashboard/src/index.css new file mode 100644 index 00000000..11eb9f6b --- /dev/null +++ b/dashboard/src/index.css @@ -0,0 +1,5 @@ +html, body, #root { + height: 100%; + margin: 0; + padding: 0; +} diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx new file mode 100644 index 00000000..81f73d46 --- /dev/null +++ b/dashboard/src/main.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +console.log('main.tsx executing...'); +const rootElement = document.getElementById('root'); +console.log('Root element found:', rootElement); + +if (!rootElement) { + console.error('Root element not found!'); + // Create a fallback element + const fallback = document.createElement('div'); + fallback.style.padding = '20px'; + fallback.style.backgroundColor = 'lightyellow'; + fallback.style.border = '2px solid red'; + fallback.innerHTML = '

Error: Root element not found

'; + document.body.appendChild(fallback); +} else { + try { + console.log('Creating root...'); + const root = createRoot(rootElement); + console.log('Root created successfully'); + + console.log('Rendering app...'); + root.render( + + + + ); + console.log('Render called successfully'); + } catch (error) { + console.error('Error rendering the app:', error); + // Display error on page + rootElement.innerHTML = ` +
+

React Rendering Error

+
${error instanceof Error ? error.message : String(error)}
+
+ `; + } +} diff --git a/dashboard/src/theme/ThemeProvider.tsx b/dashboard/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..0b36031f --- /dev/null +++ b/dashboard/src/theme/ThemeProvider.tsx @@ -0,0 +1,91 @@ +import { createTheme, ThemeProvider, CssBaseline } from "@mui/material" +import { useMemo, type ReactNode } from "react" + +interface ThemeContextProps { + children: ReactNode + mode?: "light" | "dark" +} + +export function MuiThemeProvider({ children, mode = "light" }: ThemeContextProps) { + const theme = useMemo( + () => + createTheme({ + palette: { + mode, + primary: { + main: "#6b46c1", + }, + secondary: { + main: "#a78bfa", + }, + background: { + default: mode === "light" ? "#f8f7fc" : "#1a1429", + paper: mode === "light" ? "#ffffff" : "#281e3d", + }, + }, + typography: { + fontFamily: "Inter, system-ui, sans-serif", + }, + shape: { + borderRadius: 8, + }, + components: { + MuiButton: { + styleOverrides: { + root: { + borderRadius: 6, + padding: "8px 16px", + fontWeight: 500, + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + "& .MuiOutlinedInput-root": { + borderRadius: 6, + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + borderRadius: 12, + }, + }, + }, + MuiCssBaseline: { + styleOverrides: { + html: { + height: "100%", + width: "100%", + }, + body: { + height: "100%", + width: "100%", + margin: 0, + padding: 0, + display: "flex", + flexDirection: "column", + }, + "#root": { + height: "100%", + width: "100%", + display: "flex", + flexDirection: "column", + }, + }, + }, + }, + }), + [mode], + ) + + return ( + + + {children} + + ) +} diff --git a/dashboard/src/vite-env.d.ts b/dashboard/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/dashboard/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/dashboard/tsconfig.app.json b/dashboard/tsconfig.app.json new file mode 100644 index 00000000..9c8a3c40 --- /dev/null +++ b/dashboard/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/dashboard/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/dashboard/tsconfig.node.json b/dashboard/tsconfig.node.json new file mode 100644 index 00000000..d9aa8a8e --- /dev/null +++ b/dashboard/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/dashboard/uiserver/go.mod b/dashboard/uiserver/go.mod new file mode 100644 index 00000000..d52ede13 --- /dev/null +++ b/dashboard/uiserver/go.mod @@ -0,0 +1,34 @@ +module open-cluster-management-io/lab/uiserver + +go 1.24.1 + +require github.com/gin-gonic/gin v1.10.0 + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/dashboard/uiserver/go.sum b/dashboard/uiserver/go.sum new file mode 100644 index 00000000..7f08abb2 --- /dev/null +++ b/dashboard/uiserver/go.sum @@ -0,0 +1,89 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/dashboard/uiserver/uiserver.go b/dashboard/uiserver/uiserver.go new file mode 100644 index 00000000..d90451e2 --- /dev/null +++ b/dashboard/uiserver/uiserver.go @@ -0,0 +1,102 @@ +package main + +import ( + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + + "github.com/gin-gonic/gin" +) + +func main() { + // Set Gin to release mode for production + gin.SetMode(gin.ReleaseMode) + + // Create Gin router + r := gin.Default() + + // Determine static files directory + staticDir := "/app/dist" // Default for Docker + if _, err := os.Stat("../dist"); err == nil { + // Local development: check if dist exists in parent directory + staticDir = "../dist" + } else if _, err := os.Stat("./dist"); err == nil { + // Alternative: check if dist exists in current directory + staticDir = "./dist" + } + + fmt.Printf("Using static directory: %s\n", staticDir) + + // Setup API proxy to forward API requests to the API container + apiHost := os.Getenv("API_HOST") + if apiHost == "" { + apiHost = "localhost:8080" // Default for same-pod communication + } + + apiURL, err := url.Parse("http://" + apiHost) + if err != nil { + fmt.Printf("Error parsing API URL: %v\n", err) + apiURL, _ = url.Parse("http://localhost:8080") + } + + proxy := httputil.NewSingleHostReverseProxy(apiURL) + + // Modify proxy to handle headers properly + proxy.ModifyResponse = func(resp *http.Response) error { + // Allow CORS + resp.Header.Set("Access-Control-Allow-Origin", "*") + resp.Header.Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + resp.Header.Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization") + return nil + } + + // API proxy routes - forward all /api/* requests to API container + r.Any("/api/*path", func(c *gin.Context) { + fmt.Printf("Proxying API request: %s %s\n", c.Request.Method, c.Request.URL.Path) + proxy.ServeHTTP(c.Writer, c.Request) + }) + + // Health check endpoint + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "service": "ocm-dashboard-frontend", + }) + }) + + // Handle favicon with GET instead of StaticFile to avoid NoRoute conflicts + faviconPath := filepath.Join(staticDir, "favicons", "favicon.ico") + r.GET("/favicon.ico", func(c *gin.Context) { + if _, err := os.Stat(faviconPath); err == nil { + c.File(faviconPath) + } else { + c.Status(http.StatusNotFound) + } + }) + + // Serve other static files + r.Static("/assets", filepath.Join(staticDir, "assets")) + r.Static("/favicons", filepath.Join(staticDir, "favicons")) + r.Static("/images", filepath.Join(staticDir, "images")) + r.StaticFile("/manifest.json", filepath.Join(staticDir, "manifest.json")) + + // Serve index.html for all other routes (SPA routing) + r.NoRoute(func(c *gin.Context) { + // Check if the requested file exists in dist directory + requestedPath := filepath.Join(staticDir, c.Request.URL.Path) + if _, err := os.Stat(requestedPath); err == nil { + c.File(requestedPath) + return + } + + // If file doesn't exist, serve index.html (for client-side routing) + c.File(filepath.Join(staticDir, "index.html")) + }) + + // Start server on port 3000 + fmt.Println("Starting server on :3000") + r.Run(":3000") +} diff --git a/dashboard/vite.config.ts b/dashboard/vite.config.ts new file mode 100644 index 00000000..8b0f57b9 --- /dev/null +++ b/dashboard/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})