diff --git a/.devcontainer/dapr/devcontainer.json b/.devcontainer/dapr/devcontainer.json new file mode 100644 index 0000000..2444bd6 --- /dev/null +++ b/.devcontainer/dapr/devcontainer.json @@ -0,0 +1,44 @@ +{ + "name": "Drasi For Dapr", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}/tutorial/dapr", + "onCreateCommand": "bash ../../.devcontainer/dapr/on-create.sh", + "postCreateCommand": "bash ../../.devcontainer/dapr/post-create.sh", + "runArgs": [ + "--privileged", + "--init" + ], + "customizations": { + "vscode": { + "extensions": [ + "DrasiProject.drasi", + "cweijan.vscode-database-client2" + ] + } + }, + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {} + }, + "containerEnv": { + "LANG": "en_US.UTF-8", + "LANGUAGE": "en_US:en", + "LC_ALL": "en_US.UTF-8" + }, + "remoteEnv": { + "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}" + }, + "forwardPorts": [8123], + "portsAttributes": { + "8123": { + "label": "Dapr + Drasi Apps", + "onAutoForward": "notify", + "protocol": "http" + } + }, + + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + } +} \ No newline at end of file diff --git a/.devcontainer/dapr/on-create.sh b/.devcontainer/dapr/on-create.sh new file mode 100644 index 0000000..e3fb155 --- /dev/null +++ b/.devcontainer/dapr/on-create.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# Copyright 2025 The Drasi Authors. +# +# 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. + +# Install kubectl +sudo apt-get update +sudo apt-get install -y ca-certificates curl gnupg +curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.31/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg +sudo chmod 644 /etc/apt/keyrings/kubernetes-apt-keyring.gpg +echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.31/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list +sudo chmod 644 /etc/apt/sources.list.d/kubernetes.list +sudo apt-get update +sudo apt-get install -y kubectl + +## Install Drasi CLI +curl -fsSL https://raw.githubusercontent.com/drasi-project/drasi-platform/main/cli/installers/install-drasi-cli.sh | /bin/bash \ No newline at end of file diff --git a/.devcontainer/dapr/post-create.sh b/.devcontainer/dapr/post-create.sh new file mode 100644 index 0000000..e1da7a0 --- /dev/null +++ b/.devcontainer/dapr/post-create.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Copyright 2025 The Drasi Authors. +# +# 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. + +set -e + +# Define k3d version to use across all environments +K3D_VERSION="v5.6.0" + +# Ensure kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "kubectl not found in PATH. Attempting to install..." + curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" + chmod +x kubectl + sudo mv kubectl /usr/local/bin/ +fi + +# Verify kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "ERROR: kubectl is still not available. Please check the installation." + exit 1 +fi + +# Install k3d if not present +if ! command -v k3d &> /dev/null; then + echo "k3d not found. Installing k3d ${K3D_VERSION}..." + curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | TAG=${K3D_VERSION} bash +else + echo "k3d is already installed: $(k3d version | grep 'k3d version' || echo 'version unknown')" +fi + +# Verify k3d is available +if ! command -v k3d &> /dev/null; then + echo "ERROR: k3d installation failed." + exit 1 +fi + +echo "Running setup script..." +# The working directory is already set by workspaceFolder in devcontainer.json +bash scripts/setup-tutorial.sh + +echo "" +echo "Setup complete! Applications are available at:" +echo " Catalog UI: http://localhost:8123/catalogue-service" +echo " Dashboard UI: http://localhost:8123/dashboard" +echo " Notifications UI: http://localhost:8123/notifications-service" +echo "" +echo "To deploy Drasi components:" +echo " kubectl apply -f drasi/sources/" +echo " kubectl apply -f drasi/queries/" +echo " kubectl apply -f drasi/reactions/" +echo "" +echo "Then run the demo scripts:" +echo " cd demo" +echo " ./demo-catalogue-service.sh" +echo " ./demo-dashboard-service.sh" +echo " ./demo-notifications-service.sh" \ No newline at end of file diff --git a/README.md b/README.md index dadbef1..b56066f 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,4 @@ Following sample applications demonstrate usage of Drasi in different scenarios: 3. [Fleet POC](https://github.com/drasi-project/learning/tree/main/apps/fleet-poc)- Drasi for an efficient solution to translate vehicle telemetery into actionable insights for Connected Fleet scenarios 5. [Non-events](https://github.com/drasi-project/learning/tree/main/apps/non-events) - An app to demonstrate Drasi's abilities to trigger alerts when events do not occur within a stipulated time window. 6. [Trivia](https://github.com/drasi-project/learning/tree/main/apps/trivia)- A trivia game app with dashboards that are updated directly by Drasi when team and player scores change or when players are inactive for a period of time. +7. [Dapr](https://github.com/drasi-project/learning/tree/main/apps/dapr) - A set of dapr applications that demonstrate Drasi's abilities within a dapr microservice ecosystem. \ No newline at end of file diff --git a/scripts/build-and-push-tutorial.sh b/scripts/build-and-push-tutorial.sh index 3b05db2..b99b8c1 100755 --- a/scripts/build-and-push-tutorial.sh +++ b/scripts/build-and-push-tutorial.sh @@ -25,7 +25,7 @@ set -e # Validate arguments if [ $# -lt 1 ]; then echo "Usage: $0 [tag]" - echo "Available tutorials: curbside-pickup, building-comfort" + echo "Available tutorials: curbside-pickup, building-comfort, dapr" echo "Default tag: latest" exit 1 fi @@ -39,7 +39,7 @@ PROJECT_ROOT="$( cd "$SCRIPT_DIR/.." && pwd )" # Validate tutorial name case $TUTORIAL in - "curbside-pickup"|"building-comfort") + "curbside-pickup"|"building-comfort"|"dapr") TUTORIAL_DIR="$PROJECT_ROOT/tutorial/$TUTORIAL" if [ ! -d "$TUTORIAL_DIR" ]; then echo "Error: Tutorial directory not found: $TUTORIAL_DIR" @@ -48,7 +48,7 @@ case $TUTORIAL in ;; *) echo "Error: Invalid tutorial name '$TUTORIAL'" - echo "Available tutorials: curbside-pickup, building-comfort" + echo "Available tutorials: curbside-pickup, building-comfort, dapr" exit 1 ;; esac @@ -123,8 +123,36 @@ case $TUTORIAL in build_and_push "dashboard" "./dashboard" build_and_push "demo" "./demo" ;; + + "dapr") + # Check for missing package-lock.json files for frontend services + check_npm_lock "./services/catalogue" + check_npm_lock "./services/dashboard" + check_npm_lock "./services/notifications/ui" + + # Build all dapr tutorial images + build_and_push "products-service" "./services/products" + build_and_push "customers-service" "./services/customers" + build_and_push "orders-service" "./services/orders" + build_and_push "reviews-service" "./services/reviews" + build_and_push "catalogue-service" "./services/catalogue" + build_and_push "dashboard-service" "./services/dashboard" + build_and_push "notifications-service" "./services/notifications" + ;; esac echo "" echo "All images for $TUTORIAL tutorial have been built and pushed with tag: $TAG" -echo "Total images built: $([ "$TUTORIAL" = "curbside-pickup" ] && echo "5" || echo "3")" \ No newline at end of file + +# Count images based on tutorial +case $TUTORIAL in + "curbside-pickup") + echo "Total images built: 5" + ;; + "building-comfort") + echo "Total images built: 3" + ;; + "dapr") + echo "Total images built: 7" + ;; +esac \ No newline at end of file diff --git a/tutorial/dapr/README.md b/tutorial/dapr/README.md new file mode 100644 index 0000000..28b7e63 --- /dev/null +++ b/tutorial/dapr/README.md @@ -0,0 +1,290 @@ +# Dapr + Drasi Tutorial + +This tutorial demonstrates how Drasi supercharges Dapr applications with real-time data change processing capabilities across multiple microservices. Originally presented at the Dapr Community Call, this scenario showcases three powerful Drasi reactions that solve common challenges in distributed microservice architectures. + +You'll see Drasi in action where: +- **Four Dapr microservices** (Products, Customers, Orders, Reviews) manage their own state stores +- **Drasi monitors** all state stores via logical replication with zero impact on services +- **Three Drasi-powered services** demonstrate real-time capabilities: + - **Catalog Service**: Materialized view combining products and review statistics + - **Dashboard**: Real-time monitoring of Gold customer delays and stock issues + - **Notifications**: Intelligent business events for inventory thresholds + +Follow along the tutorial instructions on [our website here](https://drasi.io/tutorials/dapr/). + +![Architecture of the Dapr + Drasi setup showing four core services, Drasi monitoring, and three Drasi-powered services](images/01-architectire-overview.png "Dapr + Drasi Tutorial Setup") + +## What You'll Learn + +- How Drasi monitors Dapr state stores without impacting service performance +- Creating materialized views across multiple services with Sync Dapr State Store reaction +- Building real-time dashboards with SignalR reaction +- Generating intelligent business events with Post Dapr Pub/Sub reaction +- Running complex queries across distributed data in real-time + +## Setup in GitHub Codespaces + +1. Open this repository in GitHub Codespaces +2. Wait for automatic setup to complete (~5 minutes) +3. When port 80 notification appears, click "Open in Browser" +4. Deploy Drasi components: + ```bash + kubectl apply -f drasi/sources/ + kubectl apply -f drasi/queries/ + kubectl apply -f drasi/reactions/ + ``` +5. Access the applications via the forwarded URL: + - Catalog UI: `https://.app.github.dev/catalogue-service` + - Dashboard: `https://.app.github.dev/dashboard` + - Notifications: `https://.app.github.dev/notifications-service` + - Products API: `https://.app.github.dev/products-service/products` + - Customers API: `https://.app.github.dev/customers-service/customers` + - Orders API: `https://.app.github.dev/orders-service/orders` + - Reviews API: `https://.app.github.dev/reviews-service/reviews` + +### Pre-configured for you: +- k3d cluster with Traefik ingress +- Drasi platform installed (includes Dapr) +- PostgreSQL databases deployed for each service +- Redis deployed for notifications +- All services running with initial data loaded + +### Troubleshooting: +- Check the **PORTS** tab in VS Code to see forwarded ports +- Ensure port 80 shows as forwarded +- The URL format is: `https://.app.github.dev` +- If using HTTPS URLs doesn't work, try the HTTP version +- Make sure the port visibility is set to "Public" if sharing the URL + +## Setup in VS Code Dev Container + +1. Prerequisites: + - Docker Desktop + - VS Code with Dev Containers extension + +2. Steps: + - Open VS Code + - Open this folder: `tutorial/dapr` + - Click "Reopen in Container" when prompted + - Wait for setup to complete (~5 minutes) + - Deploy Drasi components: + ```bash + kubectl apply -f drasi/sources/ + kubectl apply -f drasi/queries/ + kubectl apply -f drasi/reactions/ + ``` + - Access applications at: + - Catalog UI: http://localhost:8123/catalogue-service + - Dashboard: http://localhost:8123/dashboard + - Notifications: http://localhost:8123/notifications-service + - Products API: http://localhost:8123/products-service/products + - Customers API: http://localhost:8123/customers-service/customers + - Orders API: http://localhost:8123/orders-service/orders + - Reviews API: http://localhost:8123/reviews-service/reviews + +### Pre-configured for you: +- k3d cluster with Traefik ingress on port 8123 +- Drasi platform installed (includes Dapr) +- PostgreSQL databases deployed for each service +- Redis deployed for notifications +- All services running with initial data loaded + +### Troubleshooting: +- Check the **PORTS** tab in VS Code to verify port 8123 is forwarded +- If not accessible, manually forward port 8123 in the PORTS tab +- Applications are already running - no need to start them manually +- Logs can be viewed with `kubectl logs deployment/` + +## Setup your own Local k3d Cluster + +### Prerequisites: +- Docker +- kubectl +- k3d +- Drasi CLI + +### Installation Instructions: +- **kubectl**: https://kubernetes.io/docs/tasks/tools/ +- **k3d**: https://k3d.io/#installation +- **Drasi CLI**: https://drasi.io/reference/command-line-interface/#get-the-drasi-cli + +### Setup Steps: + +1. **Navigate to the tutorial directory** + ```bash + cd tutorial/dapr + ``` + +2. **Run the setup script** + ```bash + # For Linux/Mac: + ./scripts/setup-tutorial.sh + + # For Windows PowerShell: + ./scripts/setup-tutorial.ps1 + ``` + +3. **Deploy Drasi components** + ```bash + kubectl apply -f drasi/sources/ + kubectl apply -f drasi/queries/ + kubectl apply -f drasi/reactions/ + ``` + +4. **Access the applications** + - Catalog UI: http://localhost:8123/catalogue-service + - Dashboard: http://localhost:8123/dashboard + - Notifications: http://localhost:8123/notifications-service + - Products API: http://localhost:8123/products-service/products + - Customers API: http://localhost:8123/customers-service/customers + - Orders API: http://localhost:8123/orders-service/orders + - Reviews API: http://localhost:8123/reviews-service/reviews + +## Running the Demos + +After setup is complete and Drasi components are deployed, explore the three demo scenarios: + +### Demo 1: Product Catalog Service +```bash +cd demo +./demo-catalogue-service.sh +``` + +This demo shows: +- How Drasi maintains a materialized view combining products and review statistics +- Real-time updates when reviews are added or products change +- No polling or API orchestration needed + +### Demo 2: Real-Time Dashboard +```bash +cd demo +./demo-dashboard-service.sh +``` + +This demo shows: +- Monitoring Gold customer orders that are delayed +- Detecting at-risk orders that can't be fulfilled due to stock +- Real-time WebSocket updates via SignalR + +### Demo 3: Intelligent Notifications +```bash +cd demo +./demo-notifications-service.sh +``` + +This demo shows: +- Different alerts for low stock (< 20 units) vs critical stock (< 5 units) +- Business event transformation from raw database changes +- Event-driven workflows via Dapr pub/sub + +## Architecture Overview + +### Core Dapr Services +Each service runs with a Dapr sidecar and uses PostgreSQL as its state store: + +- **Products Service** (`/products-service`): Manages product inventory +- **Customers Service** (`/customers-service`): Handles customer tiers +- **Orders Service** (`/orders-service`): Processes customer orders +- **Reviews Service** (`/reviews-service`): Manages product reviews + +### Drasi Components + +#### Sources +Drasi sources monitor the PostgreSQL databases backing the Dapr state stores via logical replication: +- `products-source`: Monitors products database +- `customers-source`: Monitors customers database +- `orders-source`: Monitors orders database +- `reviews-source`: Monitors reviews database + +#### Continuous Queries +Written in Cypher, these queries detect patterns across services: +- `product-catalogue`: Joins products with review statistics +- `delayed-gold-orders`: Detects Gold customers with stuck orders +- `at-risk-orders`: Finds orders that can't be fulfilled +- `low-stock-event`: Products below 20 units +- `critical-stock-event`: Products below 5 units + +#### Reactions +- **Sync Dapr State Store**: Updates catalog service's materialized view +- **SignalR**: Powers real-time dashboard updates +- **Post Dapr Pub/Sub**: Publishes intelligent business events + +### Drasi-Powered Services + +- **Catalog Service** (`/catalogue-service`): Reads from Drasi-maintained materialized view +- **Dashboard** (`/dashboard`): Connects to SignalR for real-time updates +- **Notifications** (`/notifications-service`): Subscribes to Drasi-generated events + +## Utility Scripts + +### Reload Services (Pull Latest Images) +```bash +# Linux/Mac: +./scripts/dev-reload.sh + +# Windows PowerShell: +./scripts/dev-reload.ps1 +``` + +### Reset Images (Force Fresh Pull) +```bash +# Linux/Mac: +./scripts/reset-images.sh + +# Windows PowerShell: +./scripts/reset-images.ps1 +``` + +### Complete Cleanup +```bash +# Linux/Mac: +./scripts/cleanup-tutorial.sh + +# Windows PowerShell: +./scripts/cleanup-tutorial.ps1 +``` + +## Troubleshooting + +### Check Service Status +```bash +kubectl get pods +kubectl get deployments +``` + +### View Drasi Components +```bash +drasi list sources +drasi list queries +drasi list reactions +``` + +### Common Issues + +**Services not accessible:** +- Check if k3d cluster is running: `k3d cluster list` +- Verify services are healthy: `kubectl get pods` +- For local setup, ensure you're using http://localhost:8123 (not https) +- For Codespaces, check the PORTS tab for the correct URL + +**No data in catalog:** +- Verify Drasi sources are deployed: `kubectl get drasissource` +- Check Drasi query status: `drasi describe query product-catalogue` +- Ensure initial data was loaded during setup + +**Dashboard not updating:** +- Verify SignalR reaction is running: `drasi describe reaction signalr-reaction` +- Check browser console for WebSocket connection errors +- Ensure the SignalR ingress is deployed: `kubectl get ingress signalr` + +**Notifications not received:** +- Check Redis is running: `kubectl get statefulset notifications-redis` +- Verify pub/sub component: `kubectl get component.dapr.io notifications-pubsub` +- Check notifications service logs: `kubectl logs deployment/notifications` + +## Learn More + +- **Drasi Documentation**: https://drasi.io +- **Dapr Documentation**: https://docs.dapr.io +- **Tutorial Walkthrough**: https://drasi.io/tutorials/dapr/ +- **Dapr Community Call Recording**: [Watch on YouTube](https://www.youtube.com/watch?v=S-ImhYfLplM&t=90s) \ No newline at end of file diff --git a/tutorial/dapr/demo/demo-catalogue-service.sh b/tutorial/dapr/demo/demo-catalogue-service.sh new file mode 100755 index 0000000..f4c251f --- /dev/null +++ b/tutorial/dapr/demo/demo-catalogue-service.sh @@ -0,0 +1,405 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Base URL for services +BASE_URL="http://localhost" + +# Track created entities for cleanup +CREATED_PRODUCT="" +CREATED_REVIEWS=() + +# Helper function to print headers +print_header() { + echo + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo -e "${CYAN}${BOLD}$1${NC}" + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo +} + +# Helper function to wait for user to continue +wait_for_continue() { + local prompt="${1:-Press Enter to continue...}" + echo -e "${YELLOW}${prompt}${NC}" + read -p "> " response +} + +# Start of demo +clear +print_header "Catalogue Service Demo - Real-time Data Synchronization with Drasi" + +echo -e "${GREEN}This demo showcases Drasi's real-time data synchronization capabilities:${NC}" +echo +echo -e "${CYAN}${BOLD}Query Demonstrated:${NC} product-catalogue-query" +echo -e "${GREEN}• Joins products with reviews${NC}" +echo -e "${GREEN}• Calculates average ratings in real-time${NC}" +echo -e "${GREEN}• Aggregates review counts${NC}" +echo +echo -e "${CYAN}${BOLD}Reaction Used:${NC} Sync Dapr State Store" +echo -e "${GREEN}• Maintains materialized view in state store${NC}" +echo -e "${GREEN}• Updates automatically on data changes${NC}" +echo -e "${GREEN}• No polling - pure event-driven${NC}" +echo + +wait_for_continue "Press Enter to begin the demo..." + +# Generate random product ID +PRODUCT_ID=$((RANDOM % 9000 + 1000)) # Random ID between 1000-9999 +echo +echo -e "${BLUE}Generated Product ID: ${PRODUCT_ID}${NC}" +echo + +# Generate product details +PRODUCT_NAMES=("Ultra HD Smart TV" "Wireless Gaming Mouse" "Mechanical Keyboard Pro" "Noise Cancelling Headphones" "4K Action Camera" "Smart Home Hub" "Portable SSD Drive" "Gaming Monitor 144Hz" "Wireless Charging Pad" "Smart Fitness Tracker") +PRODUCT_DESCRIPTIONS=("Latest technology with stunning visuals" "High precision gaming mouse with RGB lighting" "Premium mechanical switches for typing enthusiasts" "Premium audio with active noise cancellation" "Capture your adventures in stunning 4K" "Control your entire smart home ecosystem" "Lightning fast storage for professionals" "Smooth gaming experience with low latency" "Fast wireless charging for all devices" "Track your health and fitness goals") + +# Pick random product details +RANDOM_INDEX=$((RANDOM % ${#PRODUCT_NAMES[@]})) +PRODUCT_NAME="${PRODUCT_NAMES[$RANDOM_INDEX]}" +PRODUCT_DESC="${PRODUCT_DESCRIPTIONS[$RANDOM_INDEX]}" +STOCK=$((RANDOM % 100 + 50)) # Random stock between 50-150 +THRESHOLD=$((RANDOM % 20 + 10)) # Random threshold between 10-30 + +print_header "Step 1: Create Product" + +echo -e "${CYAN}Creating product with the following details:${NC}" +echo -e "${CYAN}• Product ID: ${PRODUCT_ID}${NC}" +echo -e "${CYAN}• Name: ${PRODUCT_NAME}${NC}" +echo -e "${CYAN}• Description: ${PRODUCT_DESC}${NC}" +echo -e "${CYAN}• Stock: ${STOCK} units${NC}" +echo + +wait_for_continue "Press Enter to create the product..." + +echo +echo -e "${GREEN}Creating product ${PRODUCT_ID}...${NC}" + +PRODUCT_JSON=$(cat < "$TEMP_FILE" +response=$(curl -s -X POST ${BASE_URL}/products-service/products \ + -H "Content-Type: application/json" \ + -d @${TEMP_FILE} \ + -w "\n%{http_code}" 2>/dev/null) +rm -f "$TEMP_FILE" + +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + echo "$body" + CREATED_PRODUCT=$PRODUCT_ID + echo + echo -e "${GREEN}✓ Product created successfully!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 CHECKPOINT #1: Check Catalogue${NC}" + echo -e "${GREEN}Run this command to check the catalogue:${NC}" + echo + echo -e "${BOLD}curl ${BASE_URL}/catalogue-service/api/catalogue/${PRODUCT_ID} | jq .${NC}" + echo + echo -e "${CYAN}Expected: Product will NOT appear yet${NC}" + echo -e "${CYAN}The catalogue only shows products that have reviews.${NC}" + echo + + wait_for_continue "Press Enter after confirming the product is NOT in the catalogue yet..." + + echo -e "${GREEN}✓ Correct! The product needs reviews to appear. Let's add some!${NC}" +else + echo -e "${RED}Failed to create product (HTTP ${http_code})${NC}" + echo "$body" + exit 1 +fi + +echo +print_header "Step 2: Add Initial Reviews (Part 1)" + +echo -e "${CYAN}Let's add 2 initial reviews to see the rating calculation:${NC}" +echo -e "${CYAN}• First review: Rating 5⭐${NC}" +echo -e "${CYAN}• Second review: Rating 4⭐${NC}" +echo -e "${CYAN}• Expected average: 4.5⭐${NC}" +echo + +wait_for_continue "Press Enter to create the first batch of reviews..." + +# Create first batch of reviews (2 reviews) +REVIEW_TEXTS=( + "Excellent product! Highly recommended." + "Good value for money. Works as expected." +) + +RATINGS=(5 4) # First batch ratings + +for i in 0 1; do + REVIEW_ID=$((${PRODUCT_ID}000 + i + 1)) + CUSTOMER_ID=$((RANDOM % 10 + 1)) + RATING=${RATINGS[$i]} + REVIEW_TEXT="${REVIEW_TEXTS[$i]}" + + echo + echo -e "${GREEN}Creating review $(($i + 1))/2 (Rating: ${RATING}⭐)...${NC}" + + REVIEW_JSON=$(cat < "$TEMP_FILE" + response=$(curl -s -X POST ${BASE_URL}/reviews-service/reviews \ + -H "Content-Type: application/json" \ + -d @${TEMP_FILE} \ + -w "\n%{http_code}" 2>/dev/null) + rm -f "$TEMP_FILE" + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + echo "$body" + CREATED_REVIEWS+=($REVIEW_ID) + echo -e "${GREEN}✓ Review created${NC}" + else + echo -e "${RED}Failed to create review (HTTP ${http_code})${NC}" + echo "$body" + fi +done + +echo +echo -e "${YELLOW}${BOLD}📊 CHECKPOINT #2: Product Now Appears with Reviews${NC}" +echo -e "${GREEN}Run this command to see the product with aggregated review data:${NC}" +echo +echo -e "${BOLD}curl ${BASE_URL}/catalogue-service/api/catalogue/${PRODUCT_ID} | jq .${NC}" +echo +echo -e "${CYAN}Expected values:${NC}" +echo -e "${CYAN}• avgRating: 4.5 (average of 5 and 4)${NC}" +echo -e "${CYAN}• reviewCount: 2${NC}" +echo -e "${CYAN}The product now appears because it has reviews!${NC}" +echo + +wait_for_continue "Press Enter after verifying avgRating=4.5 and reviewCount=2..." + +echo -e "${GREEN}✓ Excellent! Drasi calculated the aggregations in real-time!${NC}" + +echo +print_header "Step 3: Add More Reviews (Part 2)" + +echo -e "${CYAN}Now let's add 3 more reviews to see the rating update:${NC}" +echo -e "${CYAN}• Third review: Rating 3⭐${NC}" +echo -e "${CYAN}• Fourth review: Rating 5⭐${NC}" +echo -e "${CYAN}• Fifth review: Rating 4⭐${NC}" +echo -e "${CYAN}• New expected average: 4.2⭐ (21/5)${NC}" +echo + +wait_for_continue "Press Enter to create the second batch of reviews..." + +# Create second batch of reviews (3 more reviews) +MORE_REVIEW_TEXTS=( + "Decent product with room for improvement." + "Outstanding! Exceeded my expectations." + "Solid choice. No complaints." +) + +MORE_RATINGS=(3 5 4) # Second batch ratings + +for i in 0 1 2; do + REVIEW_ID=$((${PRODUCT_ID}000 + i + 3)) # Continue from review 3 + CUSTOMER_ID=$((RANDOM % 10 + 11)) # Different customer IDs + RATING=${MORE_RATINGS[$i]} + REVIEW_TEXT="${MORE_REVIEW_TEXTS[$i]}" + + echo + echo -e "${GREEN}Creating review $(($i + 3))/5 (Rating: ${RATING}⭐)...${NC}" + + REVIEW_JSON=$(cat < "$TEMP_FILE" + response=$(curl -s -X POST ${BASE_URL}/reviews-service/reviews \ + -H "Content-Type: application/json" \ + -d @${TEMP_FILE} \ + -w "\n%{http_code}" 2>/dev/null) + rm -f "$TEMP_FILE" + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + echo "$body" + CREATED_REVIEWS+=($REVIEW_ID) + echo -e "${GREEN}✓ Review created${NC}" + else + echo -e "${RED}Failed to create review (HTTP ${http_code})${NC}" + echo "$body" + fi +done + +echo +echo -e "${YELLOW}${BOLD}📊 CHECKPOINT #3: Check Updated Aggregations${NC}" +echo -e "${GREEN}Run this command to see the updated aggregations:${NC}" +echo +echo -e "${BOLD}curl ${BASE_URL}/catalogue-service/api/catalogue/${PRODUCT_ID} | jq .${NC}" +echo +echo -e "${CYAN}Expected values:${NC}" +echo -e "${CYAN}• avgRating: 4.2 (average of 5, 4, 3, 5, 4 = 21/5)${NC}" +echo -e "${CYAN}• reviewCount: 5${NC}" +echo -e "${CYAN}Notice how both values updated automatically!${NC}" +echo + +wait_for_continue "Press Enter after verifying avgRating=4.2 and reviewCount=5..." + +echo -e "${GREEN}✓ Perfect! Drasi recalculated both aggregations with all 5 reviews!${NC}" + +# Summary +print_header "Demo Complete!" + +echo -e "${GREEN}${BOLD}What You Demonstrated:${NC}" +echo +echo -e "${CYAN}${BOLD}Drasi Query: product-catalogue-query${NC}" +echo -e "${GREEN}✓ Joined products with reviews in real-time${NC}" +echo -e "${GREEN}✓ Calculated average ratings automatically${NC}" +echo -e "${GREEN}✓ Updated aggregations as new reviews arrived${NC}" +echo -e "${GREEN}✓ Maintained accurate review counts${NC}" +echo +echo -e "${CYAN}${BOLD}Drasi Reaction: Sync Dapr State Store${NC}" +echo -e "${GREEN}✓ Synchronized query results to catalogue state store${NC}" +echo -e "${GREEN}✓ Updated materialized view without polling${NC}" +echo -e "${GREEN}✓ Provided instant access to aggregated data${NC}" +echo +echo -e "${YELLOW}${BOLD}Key Observations:${NC}" +echo -e "${CYAN}• Product alone did NOT appear in catalogue${NC}" +echo -e "${CYAN}• After first 2 reviews: Product appeared with avgRating=4.5, reviewCount=2${NC}" +echo -e "${CYAN}• After 3 more reviews: Updated to avgRating=4.2, reviewCount=5${NC}" +echo -e "${CYAN}• Both aggregations (avg and count) updated automatically${NC}" +echo -e "${CYAN}• All updates happened in real-time via CDC${NC}" +echo -e "${CYAN}• No polling or manual refresh required${NC}" +echo +echo -e "${GREEN}${BOLD}This demonstrates how Drasi enables:${NC}" +echo -e "${GREEN}• Real-time data synchronization${NC}" +echo -e "${GREEN}• Automatic aggregation calculations${NC}" +echo -e "${GREEN}• Materialized views with live updates${NC}" +echo -e "${GREEN}• Event-driven architecture${NC}" +echo + +# Cleanup section +print_header "Optional: Clean Up Demo Data" + +echo -e "${CYAN}Would you like to clean up all the demo data created?${NC}" +echo -e "${CYAN}This will delete:${NC}" +if [ ${#CREATED_REVIEWS[@]} -gt 0 ]; then + echo -e "${CYAN}• ${#CREATED_REVIEWS[@]} reviews (IDs: ${CREATED_REVIEWS[@]})${NC}" +fi +if [ ! -z "$CREATED_PRODUCT" ]; then + echo -e "${CYAN}• 1 product (ID: ${CREATED_PRODUCT})${NC}" +fi +echo + +echo -e "${YELLOW}Note: Cleanup allows you to run this demo again with a clean environment.${NC}" +echo + +# Ask if user wants to cleanup +echo -e "${YELLOW}Do you want to clean up the demo data? (yes/no):${NC}" +read -p "> " cleanup_response + +if [[ "$cleanup_response" =~ ^[Yy][Ee]?[Ss]?$ ]]; then + echo + echo -e "${GREEN}Starting cleanup...${NC}" + echo + + # Track cleanup success + cleanup_failed=false + + # Delete reviews first (they reference products) + if [ ${#CREATED_REVIEWS[@]} -gt 0 ]; then + echo -e "${YELLOW}Deleting reviews...${NC}" + for review_id in "${CREATED_REVIEWS[@]}"; do + if [ ! -z "$review_id" ]; then + echo -n " Deleting review ${review_id}... " + response=$(curl -s -X DELETE "${BASE_URL}/reviews-service/reviews/${review_id}" -w "\n%{http_code}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "204" ] || [ "$http_code" = "404" ]; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗ (HTTP ${http_code})${NC}" + cleanup_failed=true + fi + fi + done + fi + + # Delete product + if [ ! -z "$CREATED_PRODUCT" ]; then + echo + echo -e "${YELLOW}Deleting product...${NC}" + echo -n " Deleting product ${CREATED_PRODUCT}... " + response=$(curl -s -X DELETE "${BASE_URL}/products-service/products/${CREATED_PRODUCT}" -w "\n%{http_code}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "204" ] || [ "$http_code" = "404" ]; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗ (HTTP ${http_code})${NC}" + cleanup_failed=true + fi + fi + + echo + if [ "$cleanup_failed" = true ]; then + echo -e "${YELLOW}⚠️ Some items could not be deleted (they may have been already deleted).${NC}" + echo -e "${YELLOW}This is normal if you've run cleanup before or items were manually deleted.${NC}" + else + echo -e "${GREEN}${BOLD}✓ Cleanup complete!${NC}" + echo -e "${GREEN}All demo data has been removed. You can run this demo again.${NC}" + fi + + # Also check if product was removed from catalogue + echo + echo -e "${CYAN}Verifying catalogue cleanup...${NC}" + echo -e "${GREEN}Run this command to confirm the product is gone:${NC}" + echo + echo -e "${BOLD}curl ${BASE_URL}/catalogue-service/api/catalogue/${PRODUCT_ID} | jq .${NC}" + echo + echo -e "${CYAN}Expected: Product should no longer exist in the catalogue${NC}" +else + echo + echo -e "${YELLOW}Skipping cleanup. Demo data will remain in the system.${NC}" + echo -e "${YELLOW}You can manually delete the entities later if needed.${NC}" +fi + +echo +echo -e "${CYAN}${BOLD}Demo script finished. Thank you for exploring Drasi!${NC}" +echo \ No newline at end of file diff --git a/tutorial/dapr/demo/demo-dashboard-service.sh b/tutorial/dapr/demo/demo-dashboard-service.sh new file mode 100755 index 0000000..a5fc935 --- /dev/null +++ b/tutorial/dapr/demo/demo-dashboard-service.sh @@ -0,0 +1,644 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Base URL for services +BASE_URL="http://localhost" + +# Track created entities for cleanup +CREATED_ORDERS=() +CREATED_PRODUCT="" +CREATED_CUSTOMERS=() + +# Helper function to print headers +print_header() { + echo + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo -e "${CYAN}${BOLD}$1${NC}" + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo +} + +# Helper function to show command +show_command() { + echo -e "${GREEN}Running command:${NC}" + echo -e "${BOLD}$1${NC}" + echo +} + +# Helper function to execute curl with retries +execute_with_retry() { + local cmd="$1" + local max_retries=3 + local retry_delay=2 + + for i in $(seq 1 $max_retries); do + # Execute command and capture output and exit code + output=$(eval "$cmd" 2>&1) + exit_code=$? + + # Check if successful or if output contains error patterns + if [ $exit_code -eq 0 ] && ! echo "$output" | grep -q "_InactiveRpcError\|Socket closed\|StatusCode.UNAVAILABLE"; then + echo "$output" + return 0 + fi + + # If it's not the last retry, wait before retrying + if [ $i -lt $max_retries ]; then + sleep $retry_delay + fi + done + + # If all retries failed, return error + return 1 +} + +# Helper function to wait for user to continue +wait_for_continue() { + local prompt="${1:-Press Enter to continue...}" + echo -e "${YELLOW}${prompt}${NC}" + read -p "> " response +} + +# Start of demo +clear +print_header "Dashboard Service Demo - Real-time Monitoring with Drasi" +echo -e "${GREEN}This demo showcases two powerful Drasi queries in sequence:${NC}" +echo +echo -e "${CYAN}${BOLD}Part 1: Stock Risk Detection${NC}" +echo -e "${GREEN}• Demonstrates the 'at-risk-orders-query'${NC}" +echo -e "${GREEN}• Shows different severity levels based on stock shortage${NC}" +echo +echo -e "${CYAN}${BOLD}Part 2: Temporal Query Detection${NC}" +echo -e "${GREEN}• Demonstrates the 'delayed-gold-orders-query'${NC}" +echo -e "${GREEN}• Uses drasi.trueFor() to detect stuck orders${NC}" +echo +echo -e "${YELLOW}${BOLD}Dashboard URL: ${BASE_URL}/dashboard${NC}" +echo -e "${YELLOW}Please open the dashboard in your browser now!${NC}" +echo + +wait_for_continue "Press Enter when you have the dashboard open..." + +# Generate random IDs for all entities +PRODUCT_ID=$((RANDOM % 9000 + 1000)) # Random ID between 1000-9999 +CUSTOMER_ID_1=$((RANDOM % 100 + 5000)) # Random customer ID 5000-5099 +CUSTOMER_ID_2=$((RANDOM % 100 + 5100)) # Random customer ID 5100-5199 +CUSTOMER_ID_3=$((RANDOM % 100 + 5200)) # Random customer ID 5200-5299 +CUSTOMER_ID_4=$((RANDOM % 100 + 5300)) # Random customer ID 5300-5399 + +echo +echo -e "${BLUE}Generated IDs for this demo:${NC}" +echo -e "${BLUE}• Product ID: ${PRODUCT_ID}${NC}" +echo -e "${BLUE}• Customer IDs: ${CUSTOMER_ID_1}, ${CUSTOMER_ID_2}, ${CUSTOMER_ID_3}, ${CUSTOMER_ID_4}${NC}" +echo + +# Stock risk scenario quantities +STOCK_ORDER_1_QTY=40 # First order quantity +STOCK_ORDER_2_QTY=60 # Second order quantity +INITIAL_STOCK=30 # Product stock (75% of first order, 50% of second) +LOW_THRESHOLD=25 # Low stock threshold + +# Temporal query scenario - separate orders +DELAY_ORDER_1_QTY=5 # Small quantities for delay demo +DELAY_ORDER_2_QTY=3 # Small quantities for delay demo + +print_header "Initial Setup: Create Customers and Product" + +echo -e "${CYAN}Creating four GOLD tier customers for our demonstrations.${NC}" +echo -e "${CYAN}All customers will be GOLD tier to trigger special monitoring.${NC}" +echo + +wait_for_continue "Press Enter to create customers and product..." + +# Create all customers +for i in 1 2 3 4; do + CUSTOMER_VAR="CUSTOMER_ID_${i}" + CUSTOMER_ID=${!CUSTOMER_VAR} + + echo + echo -e "${GREEN}Creating Customer ${i} (ID: ${CUSTOMER_ID}, GOLD tier)...${NC}" + + CUSTOMER_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/customers-service/customers -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + CREATED_CUSTOMERS+=($CUSTOMER_ID) + else + echo -e "${RED}Failed to create customer ${i}${NC}" + exit 1 + fi +done + +# Now create the product +echo +echo -e "${GREEN}Creating product ${PRODUCT_ID} with limited stock...${NC}" +echo -e "${CYAN}• Initial stock: ${INITIAL_STOCK} units${NC}" +echo -e "${CYAN}• Low stock threshold: ${LOW_THRESHOLD} units${NC}" + + PRODUCT_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/products-service/products -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + CREATED_PRODUCT=$PRODUCT_ID + else + echo -e "${RED}Failed to create product${NC}" + exit 1 + fi + +echo +print_header "PART 1: Demonstrating at-risk-orders-query" + +echo -e "${CYAN}${BOLD}Query: at-risk-orders-query${NC}" +echo -e "${GREEN}This query detects orders where requested quantity exceeds available stock.${NC}" +echo -e "${GREEN}We'll create two orders with different shortage levels to show severity classification.${NC}" +echo + +wait_for_continue "Press Enter to start Part 1..." + +echo +print_header "Part 1 - Order 1: Medium/High Severity Stock Risk" + +echo -e "${CYAN}Creating first order that exceeds available stock:${NC}" +echo -e "${CYAN}• Customer: ${CUSTOMER_ID_1} (GOLD tier)${NC}" +echo -e "${CYAN}• Product: ${PRODUCT_ID}${NC}" +echo -e "${CYAN}• Quantity requested: ${STOCK_ORDER_1_QTY} units${NC}" +echo -e "${CYAN}• Available stock: ${INITIAL_STOCK} units${NC}" +echo +echo -e "${YELLOW}⚠️ This creates a shortage of $((STOCK_ORDER_1_QTY - INITIAL_STOCK)) units (75% fulfillment)${NC}" +echo -e "${YELLOW}⚠️ Expected severity: MEDIUM to HIGH${NC}" +echo + +wait_for_continue "Press Enter to create the first order..." + +ORDER_1_ID=$((RANDOM % 9000 + 10000)) # Random order ID + +echo +echo -e "${GREEN}Creating order ${ORDER_1_ID}...${NC}" + +ORDER_1_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/orders-service/orders -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + CREATED_ORDERS+=($ORDER_1_ID) + echo + echo -e "${GREEN}✓ Order ${ORDER_1_ID} created successfully!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #1:${NC}" + echo -e "${GREEN}1. Go to the 'Stock Risk Orders' tab${NC}" + echo -e "${GREEN}2. You should see Order ${ORDER_1_ID} appear immediately${NC}" + echo -e "${GREEN}3. Note the severity level and shortage amount${NC}" + echo + + wait_for_continue "Press Enter after observing the first order in the dashboard..." + + echo -e "${GREEN}✓ Good! Now let's create a second order with even higher shortage.${NC}" + else + echo -e "${RED}Failed to create order 1${NC}" + exit 1 + fi + +echo +print_header "Part 1 - Order 2: Critical Severity Stock Risk" + +echo -e "${CYAN}Creating second order with even higher stock shortage:${NC}" +echo -e "${CYAN}• Customer: ${CUSTOMER_ID_2} (GOLD tier)${NC}" +echo -e "${CYAN}• Product: ${PRODUCT_ID}${NC}" +echo -e "${CYAN}• Quantity requested: ${STOCK_ORDER_2_QTY} units${NC}" +echo -e "${CYAN}• Available stock: Still only ${INITIAL_STOCK} units${NC}" +echo +echo -e "${YELLOW}⚠️ This creates a shortage of $((STOCK_ORDER_2_QTY - INITIAL_STOCK)) units (50% fulfillment)${NC}" +echo -e "${YELLOW}⚠️ Expected severity: CRITICAL or HIGH${NC}" +echo + +wait_for_continue "Press Enter to create the second order..." + +ORDER_2_ID=$((RANDOM % 9000 + 20000)) # Random order ID + +echo +echo -e "${GREEN}Creating order ${ORDER_2_ID}...${NC}" + +ORDER_2_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/orders-service/orders -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + CREATED_ORDERS+=($ORDER_2_ID) + echo + echo -e "${GREEN}✓ Order ${ORDER_2_ID} created successfully!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #2:${NC}" + echo -e "${GREEN}1. Stay in the 'Stock Risk Orders' tab${NC}" + echo -e "${GREEN}2. You should now see TWO orders with different severities:${NC}" + echo -e "${GREEN} • Order ${ORDER_1_ID}: Shortage of $((STOCK_ORDER_1_QTY - INITIAL_STOCK)) units${NC}" + echo -e "${GREEN} • Order ${ORDER_2_ID}: Shortage of $((STOCK_ORDER_2_QTY - INITIAL_STOCK)) units${NC}" + echo -e "${GREEN}3. Notice the different severity levels based on shortage percentage${NC}" + echo + + wait_for_continue "Press Enter after comparing both orders in the dashboard..." + + echo -e "${GREEN}✓ Excellent! Part 1 complete - at-risk-orders-query demonstrated!${NC}" + else + echo -e "${RED}Failed to create order 2${NC}" + exit 1 + fi + +echo +print_header "PART 2: Demonstrating delayed-gold-orders-query" + +echo -e "${CYAN}${BOLD}Query: delayed-gold-orders-query${NC}" +echo -e "${GREEN}This query uses the temporal function: drasi.trueFor(o.orderStatus = 'PROCESSING', duration({seconds: 10}))${NC}" +echo -e "${GREEN}It detects GOLD customer orders stuck in PROCESSING state for more than 10 seconds.${NC}" +echo -e "${GREEN}We'll create two separate orders and update them to PROCESSING to trigger this query.${NC}" +echo + +wait_for_continue "Press Enter to start Part 2..." + +echo +print_header "Part 2 - Delayed Order 1" + +echo -e "${CYAN}Creating first order for temporal query demonstration:${NC}" +echo -e "${CYAN}• Customer: ${CUSTOMER_ID_3} (GOLD tier)${NC}" +echo -e "${CYAN}• Product: ${PRODUCT_ID}${NC}" +echo -e "${CYAN}• Quantity: ${DELAY_ORDER_1_QTY} units (small quantity, no stock issue)${NC}" +echo + +wait_for_continue "Press Enter to create the order..." + +DELAY_ORDER_1_ID=$((RANDOM % 9000 + 30000)) # Random order ID + +echo +echo -e "${GREEN}Creating order ${DELAY_ORDER_1_ID}...${NC}" + +DELAY_ORDER_1_JSON=$(cat < "$TEMP_FILE" +output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/orders-service/orders -H 'Content-Type: application/json' -d @${TEMP_FILE}") +rm -f "$TEMP_FILE" + +if [ $? -eq 0 ]; then + echo "$output" + CREATED_ORDERS+=($DELAY_ORDER_1_ID) + echo + echo -e "${GREEN}✓ Order ${DELAY_ORDER_1_ID} created successfully!${NC}" + + # Now update it to PROCESSING + echo + echo -e "${YELLOW}Now updating order ${DELAY_ORDER_1_ID} to PROCESSING status...${NC}" + echo -e "${YELLOW}The temporal query will trigger after 10 seconds.${NC}" + echo + + UPDATE_JSON='{"status": "PROCESSING"}' + + show_command "curl -X PUT ${BASE_URL}/orders-service/orders/${DELAY_ORDER_1_ID}/status \\ + -H \"Content-Type: application/json\" \\ + -d '${UPDATE_JSON}'" + + TEMP_FILE=$(mktemp) + echo "$UPDATE_JSON" > "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X PUT ${BASE_URL}/orders-service/orders/${DELAY_ORDER_1_ID}/status -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + echo + echo -e "${GREEN}✓ Order ${DELAY_ORDER_1_ID} status updated to PROCESSING!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #3 (Time-sensitive):${NC}" + echo -e "${GREEN}1. Switch to the 'Gold Customer Delays' tab${NC}" + echo -e "${GREEN}2. Wait approximately 10-12 seconds${NC}" + echo -e "${GREEN}3. Order ${DELAY_ORDER_1_ID} will appear after 10 seconds${NC}" + echo -e "${GREEN}4. Notice the live duration counter incrementing${NC}" + echo + + wait_for_continue "Press Enter after seeing order ${DELAY_ORDER_1_ID} appear in 'Gold Customer Delays'..." + + echo -e "${GREEN}✓ Great! Now let's add a second delayed order.${NC}" + else + echo -e "${RED}Failed to update order status${NC}" + exit 1 + fi +else + echo -e "${RED}Failed to create delay order 1${NC}" + exit 1 +fi + +echo +print_header "Part 2 - Delayed Order 2" + +echo -e "${CYAN}Creating second order for temporal query demonstration:${NC}" +echo -e "${CYAN}• Customer: ${CUSTOMER_ID_4} (GOLD tier)${NC}" +echo -e "${CYAN}• Product: ${PRODUCT_ID}${NC}" +echo -e "${CYAN}• Quantity: ${DELAY_ORDER_2_QTY} units (small quantity, no stock issue)${NC}" +echo + +wait_for_continue "Press Enter to create and update the second order..." + +DELAY_ORDER_2_ID=$((RANDOM % 9000 + 40000)) # Random order ID + +echo +echo -e "${GREEN}Creating order ${DELAY_ORDER_2_ID}...${NC}" + +DELAY_ORDER_2_JSON=$(cat < "$TEMP_FILE" +output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/orders-service/orders -H 'Content-Type: application/json' -d @${TEMP_FILE}") +rm -f "$TEMP_FILE" + +if [ $? -eq 0 ]; then + echo "$output" + CREATED_ORDERS+=($DELAY_ORDER_2_ID) + echo + echo -e "${GREEN}✓ Order ${DELAY_ORDER_2_ID} created!${NC}" + + # Update to PROCESSING + echo + echo -e "${YELLOW}Updating order ${DELAY_ORDER_2_ID} to PROCESSING...${NC}" + + TEMP_FILE=$(mktemp) + echo "$UPDATE_JSON" > "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X PUT ${BASE_URL}/orders-service/orders/${DELAY_ORDER_2_ID}/status -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ]; then + echo "$output" + echo + echo -e "${GREEN}✓ Order ${DELAY_ORDER_2_ID} status updated to PROCESSING!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #4 (Time-sensitive):${NC}" + echo -e "${GREEN}1. Stay in the 'Gold Customer Delays' tab${NC}" + echo -e "${GREEN}2. Wait approximately 10-12 seconds${NC}" + echo -e "${GREEN}3. Order ${DELAY_ORDER_2_ID} will appear after 10 seconds${NC}" + echo -e "${GREEN}4. You'll now see TWO orders with live duration counters${NC}" + echo + + wait_for_continue "Press Enter after seeing both delayed orders in the dashboard..." + + echo -e "${GREEN}✓ Perfect! Part 2 complete - delayed-gold-orders-query demonstrated!${NC}" + else + echo -e "${RED}Failed to update order 2 status${NC}" + exit 1 + fi +else + echo -e "${RED}Failed to create delay order 2${NC}" + exit 1 +fi + +echo +print_header "Demo Complete!" + +echo -e "${GREEN}${BOLD}Summary of What You Demonstrated:${NC}" +echo + +echo -e "${CYAN}${BOLD}Part 1: at-risk-orders-query${NC}" +echo -e "${GREEN}✓ Created 2 orders with different stock shortages${NC}" +echo -e "${GREEN}✓ Observed immediate detection of stock risks${NC}" +echo -e "${GREEN}✓ Saw different severity levels based on shortage percentage${NC}" +echo -e "${GREEN} • Order ${ORDER_1_ID}: $((STOCK_ORDER_1_QTY - INITIAL_STOCK)) units short (75% fulfillment)${NC}" +echo -e "${GREEN} • Order ${ORDER_2_ID}: $((STOCK_ORDER_2_QTY - INITIAL_STOCK)) units short (50% fulfillment)${NC}" + +echo +echo -e "${CYAN}${BOLD}Part 2: delayed-gold-orders-query${NC}" +echo -e "${GREEN}✓ Created 2 orders and set them to PROCESSING${NC}" +echo -e "${GREEN}✓ Observed temporal query triggering after 10 seconds${NC}" +echo -e "${GREEN}✓ Saw live duration counters for stuck orders${NC}" +echo -e "${GREEN} • Order ${DELAY_ORDER_1_ID}: Detected after 10+ seconds in PROCESSING${NC}" +echo -e "${GREEN} • Order ${DELAY_ORDER_2_ID}: Detected after 10+ seconds in PROCESSING${NC}" + +echo +echo -e "${YELLOW}${BOLD}Key Drasi Capabilities Demonstrated:${NC}" +echo -e "${CYAN}✓ Real-time change detection via CDC${NC}" +echo -e "${CYAN}✓ Complex joins across multiple data sources${NC}" +echo -e "${CYAN}✓ Temporal functions (drasi.trueFor) without polling${NC}" +echo -e "${CYAN}✓ Push-based updates via SignalR${NC}" +echo -e "${CYAN}✓ Severity classification and business logic in queries${NC}" +echo -e "${CYAN}✓ Event-driven architecture with zero polling${NC}" + +echo +echo -e "${GREEN}${BOLD}This demonstrates how Drasi empowers Dapr applications with:${NC}" +echo -e "${GREEN}• Real-time monitoring and alerting${NC}" +echo -e "${GREEN}• Complex event processing${NC}" +echo -e "${GREEN}• Time-based condition detection${NC}" +echo -e "${GREEN}• Live dashboards without polling${NC}" + +echo +echo -e "${BOLD}${YELLOW}Thank you for exploring Drasi's real-time capabilities!${NC}" +echo + +# Cleanup section +print_header "Optional: Clean Up Demo Data" + +echo -e "${CYAN}Would you like to clean up all the demo data created?${NC}" +echo -e "${CYAN}This will delete:${NC}" +echo -e "${CYAN}• ${#CREATED_ORDERS[@]} orders${NC}" +if [ ${#CREATED_ORDERS[@]} -gt 0 ]; then + echo -e "${CYAN} IDs: ${CREATED_ORDERS[@]}${NC}" +fi +if [ ! -z "$CREATED_PRODUCT" ]; then + echo -e "${CYAN}• 1 product (ID: ${CREATED_PRODUCT})${NC}" +fi +echo -e "${CYAN}• ${#CREATED_CUSTOMERS[@]} customers${NC}" +if [ ${#CREATED_CUSTOMERS[@]} -gt 0 ]; then + echo -e "${CYAN} IDs: ${CREATED_CUSTOMERS[@]}${NC}" +fi +echo + +echo -e "${YELLOW}Note: Cleanup allows you to run this demo again with a clean environment.${NC}" +echo + +# Ask if user wants to cleanup +echo -e "${YELLOW}Do you want to clean up the demo data? (yes/no):${NC}" +read -p "> " cleanup_response + +if [[ "$cleanup_response" =~ ^[Yy][Ee]?[Ss]?$ ]]; then + echo + echo -e "${GREEN}Starting cleanup...${NC}" + echo + + # Track cleanup success + cleanup_failed=false + + # Delete orders first (they reference products and customers) + if [ ${#CREATED_ORDERS[@]} -gt 0 ]; then + echo -e "${YELLOW}Deleting orders...${NC}" + for order_id in "${CREATED_ORDERS[@]}"; do + if [ ! -z "$order_id" ]; then + echo -n " Deleting order ${order_id}... " + response=$(curl -s -X DELETE "${BASE_URL}/orders-service/orders/${order_id}" -w "\n%{http_code}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "204" ] || [ "$http_code" = "404" ]; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗ (HTTP ${http_code})${NC}" + cleanup_failed=true + fi + fi + done + fi + + # Delete product + if [ ! -z "$CREATED_PRODUCT" ]; then + echo + echo -e "${YELLOW}Deleting product...${NC}" + echo -n " Deleting product ${CREATED_PRODUCT}... " + response=$(curl -s -X DELETE "${BASE_URL}/products-service/products/${CREATED_PRODUCT}" -w "\n%{http_code}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "204" ] || [ "$http_code" = "404" ]; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗ (HTTP ${http_code})${NC}" + cleanup_failed=true + fi + fi + + # Delete customers + if [ ${#CREATED_CUSTOMERS[@]} -gt 0 ]; then + echo + echo -e "${YELLOW}Deleting customers...${NC}" + for customer_id in "${CREATED_CUSTOMERS[@]}"; do + if [ ! -z "$customer_id" ]; then + echo -n " Deleting customer ${customer_id}... " + response=$(curl -s -X DELETE "${BASE_URL}/customers-service/customers/${customer_id}" -w "\n%{http_code}" 2>/dev/null) + http_code=$(echo "$response" | tail -1) + + if [ "$http_code" = "204" ] || [ "$http_code" = "404" ]; then + echo -e "${GREEN}✓${NC}" + else + echo -e "${RED}✗ (HTTP ${http_code})${NC}" + cleanup_failed=true + fi + fi + done + fi + + echo + if [ "$cleanup_failed" = true ]; then + echo -e "${YELLOW}⚠️ Some items could not be deleted (they may have been already deleted).${NC}" + echo -e "${YELLOW}This is normal if you've run cleanup before or items were manually deleted.${NC}" + else + echo -e "${GREEN}${BOLD}✓ Cleanup complete!${NC}" + echo -e "${GREEN}All demo data has been removed. You can run this demo again.${NC}" + fi +else + echo + echo -e "${YELLOW}Skipping cleanup. Demo data will remain in the system.${NC}" + echo -e "${YELLOW}You can manually delete the entities later if needed.${NC}" +fi + +echo +echo -e "${CYAN}${BOLD}Demo script finished. Goodbye!${NC}" +echo \ No newline at end of file diff --git a/tutorial/dapr/demo/demo-notifications-service.sh b/tutorial/dapr/demo/demo-notifications-service.sh new file mode 100755 index 0000000..06943e7 --- /dev/null +++ b/tutorial/dapr/demo/demo-notifications-service.sh @@ -0,0 +1,510 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Base URL for services +BASE_URL="http://localhost" + +# Track created entities for cleanup +CREATED_PRODUCTS=() +MODIFIED_PRODUCTS=() +ORIGINAL_STOCK_VALUES=() + +# Helper function to print headers +print_header() { + echo + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo -e "${CYAN}${BOLD}$1${NC}" + echo -e "${CYAN}${BOLD}===================================================${NC}" + echo +} + +# Helper function to show command +show_command() { + echo -e "${GREEN}Running command:${NC}" + echo -e "${BOLD}$1${NC}" + echo +} + +# Helper function to execute curl with retries +execute_with_retry() { + local cmd="$1" + local max_retries=3 + local retry_delay=2 + + for i in $(seq 1 $max_retries); do + # Execute command and capture output and exit code + output=$(eval "$cmd" 2>&1) + exit_code=$? + + # Check if successful or if output contains error patterns + if [ $exit_code -eq 0 ] && ! echo "$output" | grep -q "_InactiveRpcError\|Socket closed\|StatusCode.UNAVAILABLE"; then + echo "$output" + return 0 + fi + + # If it's not the last retry, wait before retrying + if [ $i -lt $max_retries ]; then + sleep $retry_delay + fi + done + + # If all retries failed, return error + return 1 +} + +# Helper function to wait for user to continue +wait_for_continue() { + local prompt="${1:-Press Enter to continue...}" + echo -e "${YELLOW}${prompt}${NC}" + read -p "> " response +} + +# Function to get current stock for a product +get_product_stock() { + local product_id=$1 + output=$(execute_with_retry "curl -s ${BASE_URL}/products-service/products/${product_id}") + if [ $? -eq 0 ]; then + # Try both camelCase and snake_case field names + stock=$(echo "$output" | grep -o '"stockOnHand":[0-9]*' | cut -d':' -f2) + if [ -z "$stock" ]; then + stock=$(echo "$output" | grep -o '"stock_on_hand":[0-9]*' | cut -d':' -f2) + fi + if [ -z "$stock" ]; then + echo "0" + else + echo "$stock" + fi + else + echo "0" + fi +} + +# Function to clean up created/modified entities +cleanup() { + echo + print_header "Cleaning Up Demo Data" + + # Restore original stock values for modified products + if [ ${#MODIFIED_PRODUCTS[@]} -gt 0 ]; then + echo -e "${YELLOW}Restoring original stock values...${NC}" + for i in "${!MODIFIED_PRODUCTS[@]}"; do + product_id="${MODIFIED_PRODUCTS[$i]}" + original_stock="${ORIGINAL_STOCK_VALUES[$i]}" + + echo -e "${BLUE}Restoring product ${product_id} stock to ${original_stock}...${NC}" + + # Get current product details first + product_data=$(execute_with_retry "curl -s ${BASE_URL}/products-service/products/${product_id}") + if [ $? -eq 0 ]; then + # Extract product details and update stock (handle both camelCase and snake_case) + product_name=$(echo "$product_data" | grep -o '"productName":"[^"]*' | cut -d'"' -f4) + if [ -z "$product_name" ]; then + product_name=$(echo "$product_data" | grep -o '"product_name":"[^"]*' | cut -d'"' -f4) + fi + + product_desc=$(echo "$product_data" | grep -o '"productDescription":"[^"]*' | cut -d'"' -f4) + if [ -z "$product_desc" ]; then + product_desc=$(echo "$product_data" | grep -o '"product_description":"[^"]*' | cut -d'"' -f4) + fi + + threshold=$(echo "$product_data" | grep -o '"lowStockThreshold":[0-9]*' | cut -d':' -f2) + if [ -z "$threshold" ]; then + threshold=$(echo "$product_data" | grep -o '"low_stock_threshold":[0-9]*' | cut -d':' -f2) + fi + + RESTORE_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/products-service/products -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + fi + + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Product ${product_id} stock restored${NC}" + else + echo -e "${RED}✗ Failed to restore product ${product_id}${NC}" + fi + done + fi + + # Delete created products + if [ ${#CREATED_PRODUCTS[@]} -gt 0 ]; then + echo -e "${YELLOW}Deleting created products...${NC}" + for product_id in "${CREATED_PRODUCTS[@]}"; do + echo -e "${BLUE}Deleting product ${product_id}...${NC}" + output=$(execute_with_retry "curl -s -X DELETE ${BASE_URL}/products-service/products/${product_id}") + if [ $? -eq 0 ]; then + echo -e "${GREEN}✓ Product ${product_id} deleted${NC}" + else + echo -e "${RED}✗ Failed to delete product ${product_id}${NC}" + fi + done + fi + + echo + echo -e "${GREEN}${BOLD}Cleanup complete!${NC}" +} + +# Set up trap to cleanup on exit +trap cleanup EXIT + +# Start of demo +clear +print_header "Notifications Service Demo - Real-time Stock Alerts with Drasi" + +echo -e "${GREEN}This demo showcases the Drasi PostDaprPubSub reaction for inventory management:${NC}" +echo +echo -e "${CYAN}${BOLD}Part 1: Low Stock Detection${NC}" +echo -e "${GREEN}• Demonstrates the 'low-stock-event-query'${NC}" +echo -e "${GREEN}• Triggers when stock falls below threshold but above zero${NC}" +echo -e "${GREEN}• Simulates email notification to purchasing team${NC}" +echo +echo -e "${CYAN}${BOLD}Part 2: Critical Stock Detection${NC}" +echo -e "${GREEN}• Demonstrates the 'critical-stock-event-query'${NC}" +echo -e "${GREEN}• Triggers when stock reaches zero${NC}" +echo -e "${GREEN}• Simulates urgent notifications to sales and fulfillment teams${NC}" +echo +echo -e "${MAGENTA}${BOLD}How Drasi + Dapr Work Together:${NC}" +echo -e "${GREEN}1. Drasi monitors the products state store via Dapr source${NC}" +echo -e "${GREEN}2. Continuous queries detect stock conditions in real-time${NC}" +echo -e "${GREEN}3. PostDaprPubSub reaction publishes CloudEvents to Redis via Dapr${NC}" +echo -e "${GREEN}4. Events flow through Redis Streams (Dapr's pub/sub broker)${NC}" +echo -e "${GREEN}5. Notifications service subscribes using standard Dapr pub/sub APIs${NC}" +echo -e "${BLUE} • Uses @dapr_app.subscribe decorator (Python SDK)${NC}" +echo -e "${BLUE} • No custom integration needed - just standard Dapr!${NC}" +echo +echo -e "${YELLOW}${BOLD}Dashboard URL: ${BASE_URL}/notifications-service${NC}" +echo -e "${YELLOW}Please open the notifications dashboard in your browser now!${NC}" +echo +echo -e "${MAGENTA}${BOLD}📧 To Monitor Email Notifications:${NC}" +echo -e "${GREEN}Open a second terminal and run:${NC}" +echo -e "${BOLD} ./demo/monitor-notifications.sh${NC}" +echo -e "${GREEN}This will display simulated email alerts as they're triggered.${NC}" +echo +echo -e "${CYAN}${BOLD}🔍 To See Redis Pub/Sub in Action:${NC}" +echo -e "${GREEN}You can inspect the Redis streams directly:${NC}" +echo -e "${BOLD} kubectl exec -it deployment/notifications-redis -- redis-cli${NC}" +echo -e "${BOLD} > XINFO STREAM low-stock-events${NC}" +echo -e "${BOLD} > XINFO STREAM critical-stock-events${NC}" +echo -e "${BLUE}Note: Dapr uses Redis Streams under the hood for pub/sub${NC}" +echo + +wait_for_continue "Press Enter when you have the dashboard open..." + +# Generate random ID for demo product +PRODUCT_ID_1=$((RANDOM % 900 + 8000)) # Random ID between 8000-8899 + +echo +echo -e "${BLUE}Generated Product ID for this demo: ${PRODUCT_ID_1}${NC}" +echo + +# ================================================== +# PART 1: LOW STOCK DETECTION +# ================================================== + +print_header "PART 1: Demonstrating Low Stock Detection" + +echo -e "${CYAN}${BOLD}Query: low-stock-event-query${NC}" +echo -e "${GREEN}This query detects: p.stockOnHand <= p.lowStockThreshold AND p.stockOnHand > 0${NC}" +echo +echo -e "${CYAN}We'll create a product with:${NC}" +echo -e "${CYAN}• Initial stock: 50 units${NC}" +echo -e "${CYAN}• Low stock threshold: 20 units${NC}" +echo -e "${CYAN}Then reduce stock to 15 units to trigger the alert${NC}" +echo + +wait_for_continue "Press Enter to create the first product..." + +# Create first product +echo +echo -e "${GREEN}Creating product ${PRODUCT_ID_1} (High-End Laptop)...${NC}" + +PRODUCT_1_JSON=$(cat < "$TEMP_FILE" +output=$(execute_with_retry "curl -s -X POST ${BASE_URL}/products-service/products -H 'Content-Type: application/json' -d @${TEMP_FILE}") +rm -f "$TEMP_FILE" + +if [ $? -eq 0 ] && ! echo "$output" | grep -q "detail"; then + echo "$output" + CREATED_PRODUCTS+=($PRODUCT_ID_1) + + # Track the original stock value for restoration later + MODIFIED_PRODUCTS+=($PRODUCT_ID_1) + ORIGINAL_STOCK_VALUES+=(50) # Original stock we just created with + + echo + echo -e "${GREEN}✓ Product ${PRODUCT_ID_1} created successfully!${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #1:${NC}" + echo -e "${GREEN}1. Check the dashboard - all counters should still be at 0${NC}" + echo -e "${GREEN}2. No events should appear yet (stock is healthy at 50 units)${NC}" + echo + + wait_for_continue "Press Enter to reduce stock below threshold..." + + # Reduce stock to trigger low stock alert + echo + echo -e "${YELLOW}Reducing stock from 50 to 15 units (below threshold of 20)...${NC}" + echo -e "${BLUE}Using decrement endpoint to simulate sales activity${NC}" + + DECREMENT_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X PUT ${BASE_URL}/products-service/products/${PRODUCT_ID_1}/decrement -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ] && ! echo "$output" | grep -q "detail"; then + echo "$output" + echo + echo -e "${GREEN}✓ Stock reduced to 15 units!${NC}" + echo + echo -e "${RED}${BOLD}🚨 LOW STOCK ALERT TRIGGERED! 🚨${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #2:${NC}" + echo -e "${GREEN}1. The 'Low Stock Events' counter should increment to 1${NC}" + echo -e "${GREEN}2. A yellow event card should appear in 'Recent Events'${NC}" + echo -e "${GREEN}3. The event shows: Product ${PRODUCT_ID_1}, Stock: 15, Threshold: 20${NC}" + echo + echo -e "${CYAN}${BOLD}🔄 WHAT JUST HAPPENED (Dapr Pub/Sub):${NC}" + echo -e "${BLUE}• Drasi detected: stockOnHand (15) <= lowStockThreshold (20)${NC}" + echo -e "${BLUE}• PostDaprPubSub reaction published to Redis topic 'low-stock-events'${NC}" + echo -e "${BLUE}• Notifications service received via Dapr subscription${NC}" + echo + echo -e "${MAGENTA}${BOLD}📧 IF MONITORING NOTIFICATIONS:${NC}" + echo -e "${GREEN}You should see a simulated email to purchasing@company.com${NC}" + echo -e "${GREEN}with recommended actions for the purchasing team${NC}" + echo + + wait_for_continue "Press Enter after observing the low stock alert..." + + echo -e "${GREEN}✓ Excellent! Low stock detection demonstrated!${NC}" + else + echo -e "${RED}Failed to decrement product stock${NC}" + echo -e "${RED}Error: $output${NC}" + exit 1 + fi +else + echo "$output" + echo -e "${RED}Failed to create product${NC}" + echo -e "${RED}Please ensure the products service is running and accessible${NC}" + exit 1 +fi + +# ================================================== +# PART 2: CRITICAL STOCK DETECTION +# ================================================== + +echo +print_header "PART 2: Demonstrating Critical Stock Detection" + +echo -e "${CYAN}${BOLD}Query: critical-stock-event-query${NC}" +echo -e "${GREEN}This query detects: p.stockOnHand = 0${NC}" +echo +echo -e "${CYAN}We'll use an existing product and set its stock to zero${NC}" +echo -e "${CYAN}This simulates a complete stockout scenario${NC}" +echo + +# Check if product 1 exists (ID 1 is typically always present) +EXISTING_PRODUCT_ID=1 +echo -e "${CYAN}Trying to use existing product ID: ${EXISTING_PRODUCT_ID}${NC}" + +# Get current stock value +CURRENT_STOCK=$(get_product_stock $EXISTING_PRODUCT_ID) + +# Check if we got a valid stock value +if [ -z "$CURRENT_STOCK" ] || [ "$CURRENT_STOCK" = "0" ]; then + echo -e "${YELLOW}Product ${EXISTING_PRODUCT_ID} has no stock or doesn't exist.${NC}" + echo -e "${YELLOW}Let's use the product we created in Part 1...${NC}" + + # Use the product we created in Part 1 instead + EXISTING_PRODUCT_ID=$PRODUCT_ID_1 + CURRENT_STOCK=$(get_product_stock $EXISTING_PRODUCT_ID) + echo -e "${BLUE}Using product ${EXISTING_PRODUCT_ID} with current stock: ${CURRENT_STOCK} units${NC}" + + # For Part 1 product, we already tracked its original value, so don't add it again + SKIP_TRACKING=true +else + echo -e "${BLUE}Current stock for product ${EXISTING_PRODUCT_ID}: ${CURRENT_STOCK} units${NC}" + SKIP_TRACKING=false +fi + +# Only track if this is a new product we haven't tracked yet +if [ "$SKIP_TRACKING" != "true" ] && [ "$CURRENT_STOCK" != "0" ] && [ -n "$CURRENT_STOCK" ]; then + # Check if this product is already being tracked + already_tracked=false + for tracked_id in "${MODIFIED_PRODUCTS[@]}"; do + if [ "$tracked_id" = "$EXISTING_PRODUCT_ID" ]; then + already_tracked=true + break + fi + done + + if [ "$already_tracked" = "false" ]; then + MODIFIED_PRODUCTS+=($EXISTING_PRODUCT_ID) + ORIGINAL_STOCK_VALUES+=($CURRENT_STOCK) + fi +fi + +wait_for_continue "Press Enter to set stock to zero (critical level)..." + +# Set stock to zero to trigger critical alert +echo +echo -e "${RED}Depleting all remaining stock to 0 units (CRITICAL - OUT OF STOCK)...${NC}" +echo -e "${BLUE}Using decrement with current stock amount to simulate complete sellout${NC}" + +# Ensure we have a valid quantity +if [ -z "$CURRENT_STOCK" ] || [ "$CURRENT_STOCK" = "0" ]; then + echo -e "${RED}Error: Invalid stock quantity. Cannot proceed with critical stock demonstration.${NC}" + echo -e "${YELLOW}Skipping to demo summary...${NC}" +else + # Decrement by the current stock amount to reach zero + DECREMENT_JSON=$(cat < "$TEMP_FILE" + output=$(execute_with_retry "curl -s -X PUT ${BASE_URL}/products-service/products/${EXISTING_PRODUCT_ID}/decrement -H 'Content-Type: application/json' -d @${TEMP_FILE}") + rm -f "$TEMP_FILE" + + if [ $? -eq 0 ] && ! echo "$output" | grep -q "detail"; then + echo "$output" + echo + echo -e "${RED}✓ Stock set to 0 units - PRODUCT IS OUT OF STOCK!${NC}" + echo + echo -e "${RED}${BOLD}🚨🚨 CRITICAL STOCK ALERT TRIGGERED! 🚨🚨${NC}" + echo + echo -e "${YELLOW}${BOLD}📊 DASHBOARD CHECKPOINT #3:${NC}" + echo -e "${GREEN}1. The 'Critical Stock Events' counter should increment to 1${NC}" + echo -e "${GREEN}2. A red event card should appear in 'Recent Events'${NC}" + echo -e "${GREEN}3. The event shows: Product ${EXISTING_PRODUCT_ID} is OUT OF STOCK${NC}" + echo + echo -e "${CYAN}${BOLD}🔄 WHAT JUST HAPPENED (Dapr Pub/Sub):${NC}" + echo -e "${BLUE}• Drasi detected: stockOnHand = 0 (critical condition)${NC}" + echo -e "${BLUE}• PostDaprPubSub reaction published to Redis topic 'critical-stock-events'${NC}" + echo -e "${BLUE}• Different topic = different severity handling${NC}" + echo -e "${BLUE}• Notifications service processed via standard Dapr subscription${NC}" + echo + echo -e "${MAGENTA}${BOLD}📧 IF MONITORING NOTIFICATIONS:${NC}" + echo -e "${GREEN}You should see TWO simulated emails:${NC}" + echo -e "${GREEN} • To sales@company.com - Halt all sales immediately${NC}" + echo -e "${GREEN} • To fulfillment@company.com - Review pending orders${NC}" + echo -e "${GREEN}Plus automated system actions (marking out of stock, etc.)${NC}" + echo + + wait_for_continue "Press Enter after observing the critical stock alert..." + + echo -e "${GREEN}✓ Perfect! Critical stock detection demonstrated!${NC}" + else + echo -e "${RED}Failed to decrement product stock to zero${NC}" + echo -e "${RED}Error: $output${NC}" + exit 1 + fi +fi + +# ================================================== +# DEMO SUMMARY +# ================================================== + +echo +print_header "Demo Summary" + +echo -e "${YELLOW}${BOLD}What You've Demonstrated:${NC}" +echo +echo -e "${GREEN}✓ ${BOLD}Low Stock Detection:${NC}" +echo -e " • Product stock fell below threshold (15 < 20)${NC}" +echo -e " • Drasi query detected the condition instantly${NC}" +echo -e " • PostDaprPubSub published to 'low-stock-events' topic${NC}" +echo -e " • Notification service received event and simulated email${NC}" +echo +echo -e "${GREEN}✓ ${BOLD}Critical Stock Detection:${NC}" +echo -e " • Product stock reached zero${NC}" +echo -e " • Different Drasi query detected this critical condition${NC}" +echo -e " • Published to 'critical-stock-events' topic${NC}" +echo -e " • Triggered urgent notifications to multiple teams${NC}" +echo +echo -e "${GREEN}✓ ${BOLD}Standard Dapr Pub/Sub Integration:${NC}" +echo -e " • Drasi published events to Redis Streams via Dapr component${NC}" +echo -e " • Notifications service subscribed using @dapr_app.subscribe${NC}" +echo -e " • No custom integration - just standard Dapr pub/sub APIs${NC}" +echo -e " • CloudEvents format ensures compatibility${NC}" +echo + +echo -e "${CYAN}${BOLD}Key Takeaways for Dapr Users:${NC}" +echo -e "${GREEN}• Drasi adds sophisticated change detection to your Dapr apps${NC}" +echo -e "${GREEN}• No need to write custom monitoring code${NC}" +echo -e "${GREEN}• Leverages existing Dapr pub/sub infrastructure${NC}" +echo -e "${GREEN}• Declarative queries instead of imperative logic${NC}" +echo -e "${GREEN}• Perfect for event-driven microservices architectures${NC}" +echo + +wait_for_continue "Press Enter to complete the demo and cleanup..." + +echo +print_header "Demo Complete!" + +echo -e "${GREEN}${BOLD}You've successfully demonstrated:${NC}" +echo -e "${GREEN}✓ Low stock detection and alerts${NC}" +echo -e "${GREEN}✓ Critical stock detection with urgent notifications${NC}" +echo -e "${GREEN}✓ Real-time event processing via Drasi + Dapr${NC}" +echo -e "${GREEN}✓ WebSocket-based dashboard updates${NC}" +echo -e "${GREEN}✓ Email simulation for different alert types${NC}" +echo +echo -e "${CYAN}${BOLD}This showcases how Drasi enhances Dapr applications by:${NC}" +echo -e "${GREEN}• Adding sophisticated change detection without custom code${NC}" +echo -e "${GREEN}• Enabling declarative business rules through queries${NC}" +echo -e "${GREEN}• Providing seamless integration with Dapr building blocks${NC}" +echo +echo -e "${YELLOW}Cleaning up demo data...${NC}" + +# Cleanup will be called automatically via trap \ No newline at end of file diff --git a/tutorial/dapr/demo/monitor-notifications.sh b/tutorial/dapr/demo/monitor-notifications.sh new file mode 100755 index 0000000..f9f5e51 --- /dev/null +++ b/tutorial/dapr/demo/monitor-notifications.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +clear +echo -e "${CYAN}${BOLD}===================================================${NC}" +echo -e "${CYAN}${BOLD}📧 Notifications Service Monitor${NC}" +echo -e "${CYAN}${BOLD}===================================================${NC}" +echo +echo -e "${GREEN}This window will display simulated email notifications${NC}" +echo -e "${GREEN}when stock levels trigger alerts.${NC}" +echo +echo -e "${YELLOW}Watching for:${NC}" +echo -e "${YELLOW}• Low stock alerts → emails to purchasing@company.com${NC}" +echo -e "${YELLOW}• Critical stock alerts → emails to sales@ and fulfillment@${NC}" +echo +echo -e "${BLUE}${BOLD}Starting log stream...${NC}" +echo -e "${CYAN}===================================================${NC}" +echo + +# Follow the notifications service logs +kubectl logs -f deployment/notifications -n default | grep -E "EMAIL NOTIFICATION|Subject:|Dear |Product Details:|Required Actions:|Stock Level:|Alert Time:|Recommended Action:|AUTOMATED SYSTEM ACTIONS|✓|════" \ No newline at end of file diff --git a/tutorial/dapr/drasi/queries/at-risk-orders.yaml b/tutorial/dapr/drasi/queries/at-risk-orders.yaml new file mode 100644 index 0000000..4b6e7cd --- /dev/null +++ b/tutorial/dapr/drasi/queries/at-risk-orders.yaml @@ -0,0 +1,86 @@ +apiVersion: v1 +kind: ContinuousQuery +name: at-risk-orders-query +spec: + mode: query + sources: + subscriptions: + - id: orders-source + nodes: + - sourceLabel: orders + pipeline: + - decode_value + - parse_value + - promote_order_details + - extract_order_items + - id: products-source + nodes: + - sourceLabel: products + pipeline: + - decode_value + - parse_value + - promote_product_details + joins: + - id: ORDER_ITEM_TO_PRODUCT + keys: + - label: orderItem + property: product_id + - label: products + property: productId + middleware: + - name: decode_value + kind: decoder + encoding_type: base64 + target_property: value + strip_quotes: true + - name: parse_value + kind: parse_json + target_property: value + output_property: parsed_properties + - name: promote_order_details + kind: promote + mappings: + - path: "$.parsed_properties.order_id" + target_name: "orderId" + - path: "$.parsed_properties.customer_id" + target_name: "customerId" + - path: "$.parsed_properties.items" + target_name: "orderItems" + - path: "$.parsed_properties.status" + target_name: "orderStatus" + - name: promote_product_details + kind: promote + mappings: + - path: "$.parsed_properties.product_id" + target_name: "productId" + - path: "$.parsed_properties.product_name" + target_name: "productName" + - path: "$.parsed_properties.product_description" + target_name: "productDescription" + - path: "$.parsed_properties.stock_on_hand" + target_name: "stockOnHand" + - path: "$.parsed_properties.low_stock_threshold" + target_name: "lowStockThreshold" + - name: extract_order_items + kind: unwind + orders: + - selector: $.orderItems[*] + label: orderItem + key: $.product_id + relation: ITEM_OF_ORDER + query: > + MATCH + (o:orders)-[:ITEM_OF_ORDER]->(oi:orderItem), + (oi)-[:ORDER_ITEM_TO_PRODUCT]->(p:products) + WHERE + o.orderStatus IN ['PENDING', 'PAID'] AND + p.stockOnHand < oi.quantity + RETURN + o.orderId AS orderId, + o.customerId AS customerId, + o.orderStatus AS orderStatus, + oi.quantity AS quantity, + p.productId AS productId, + p.productName AS productName, + p.stockOnHand AS stockOnHand + \ No newline at end of file diff --git a/tutorial/dapr/drasi/queries/critical-stock-event.yaml b/tutorial/dapr/drasi/queries/critical-stock-event.yaml new file mode 100644 index 0000000..4e851a4 --- /dev/null +++ b/tutorial/dapr/drasi/queries/critical-stock-event.yaml @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: ContinuousQuery +name: critical-stock-event-query +spec: + mode: query + sources: + subscriptions: + - id: products-source + nodes: + - sourceLabel: products + pipeline: + - decode_value + - parse_value + - promote_product_details + middleware: + - name: decode_value + kind: decoder + encoding_type: base64 + target_property: value + strip_quotes: true + - name: parse_value + kind: parse_json + target_property: value + - name: promote_product_details + kind: promote + mappings: + - path: "$.value.product_id" + target_name: "productId" + - path: "$.value.product_name" + target_name: "productName" + - path: "$.value.product_description" + target_name: "productDescription" + - path: "$.value.stock_on_hand" + target_name: "stockOnHand" + - path: "$.value.low_stock_threshold" + target_name: "lowStockThreshold" + query: > + MATCH + (p:products) + WHERE + p.stockOnHand = 0 + RETURN + p.productId AS productId, + p.productName AS productName, + p.productDescription AS productDescription \ No newline at end of file diff --git a/tutorial/dapr/drasi/queries/delayed-gold-orders.yaml b/tutorial/dapr/drasi/queries/delayed-gold-orders.yaml new file mode 100644 index 0000000..22e5ce5 --- /dev/null +++ b/tutorial/dapr/drasi/queries/delayed-gold-orders.yaml @@ -0,0 +1,77 @@ +apiVersion: v1 +kind: ContinuousQuery +name: delayed-gold-orders-query +spec: + mode: query + sources: + subscriptions: + - id: orders-source + nodes: + - sourceLabel: orders + pipeline: + - decode_value + - parse_value + - promote_order_details + - id: customers-source + nodes: + - sourceLabel: customers + pipeline: + - decode_value + - parse_value + - promote_customer_details + joins: + - id: ORDER_TO_CUSTOMER + keys: + - label: orders + property: customerId + - label: customers + property: customerId + middleware: + - name: decode_value + kind: decoder + encoding_type: base64 + target_property: value + strip_quotes: true + - name: parse_value + kind: parse_json + target_property: value + output_property: parsed_properties + - name: promote_order_details + kind: promote + mappings: + - path: "$.parsed_properties.order_id" + target_name: "orderId" + - path: "$.parsed_properties.customer_id" + target_name: "customerId" + - path: "$.parsed_properties.items" + target_name: "orderItems" + - path: "$.parsed_properties.status" + target_name: "orderStatus" + - name: promote_customer_details + kind: promote + mappings: + - path: "$.parsed_properties.customer_id" + target_name: "customerId" + - path: "$.parsed_properties.customer_name" + target_name: "customerName" + - path: "$.parsed_properties.loyalty_tier" + target_name: "loyaltyTier" + - path: "$.parsed_properties.email" + target_name: "customerEmail" + query: > + MATCH + (o:orders)-[:ORDER_TO_CUSTOMER]->(c:customers) + WHERE + c.loyaltyTier = 'GOLD' + WITH + o, c, drasi.changeDateTime(o) as waitingSince + WHERE + waitingSince != datetime({epochMillis: 0}) AND + drasi.trueFor(o.orderStatus = 'PROCESSING', duration ({ seconds: 10 })) + RETURN + o.orderId AS orderId, + c.customerId AS customerId, + c.customerName AS customerName, + c.customerEmail AS customerEmail, + o.orderStatus AS orderStatus, + waitingSince \ No newline at end of file diff --git a/tutorial/dapr/drasi/queries/low-stock-event.yaml b/tutorial/dapr/drasi/queries/low-stock-event.yaml new file mode 100644 index 0000000..8ecfc92 --- /dev/null +++ b/tutorial/dapr/drasi/queries/low-stock-event.yaml @@ -0,0 +1,46 @@ +apiVersion: v1 +kind: ContinuousQuery +name: low-stock-event-query +spec: + mode: query + sources: + subscriptions: + - id: products-source + nodes: + - sourceLabel: products + pipeline: + - decode_value + - parse_value + - promote_product_details + middleware: + - name: decode_value + kind: decoder + encoding_type: base64 + target_property: value + strip_quotes: true + - name: parse_value + kind: parse_json + target_property: value + - name: promote_product_details + kind: promote + mappings: + - path: "$.value.product_id" + target_name: "productId" + - path: "$.value.product_name" + target_name: "productName" + - path: "$.value.product_description" + target_name: "productDescription" + - path: "$.value.stock_on_hand" + target_name: "stockOnHand" + - path: "$.value.low_stock_threshold" + target_name: "lowStockThreshold" + query: > + MATCH + (p:products) + WHERE + p.stockOnHand <= p.lowStockThreshold AND p.stockOnHand > 0 + RETURN + p.productId AS productId, + p.productName AS productName, + p.stockOnHand AS stockOnHand, + p.lowStockThreshold AS lowStockThreshold \ No newline at end of file diff --git a/tutorial/dapr/drasi/queries/product-catalogue.yaml b/tutorial/dapr/drasi/queries/product-catalogue.yaml new file mode 100644 index 0000000..f2f33cb --- /dev/null +++ b/tutorial/dapr/drasi/queries/product-catalogue.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: ContinuousQuery +name: product-catalogue-query +spec: + mode: query + sources: + subscriptions: + - id: products-source + nodes: + - sourceLabel: products + pipeline: + - decode_value + - parse_value + - promote_product_details + - id: reviews-source + nodes: + - sourceLabel: reviews + pipeline: + - decode_value + - parse_value + - promote_review_details + middleware: + - name: decode_value + kind: decoder + encoding_type: base64 + target_property: value + strip_quotes: true + - name: parse_value + kind: parse_json + target_property: value + output_property: parsed_properties + - name: promote_review_details + kind: promote + mappings: + - path: "$.parsed_properties.review_id" + target_name: "reviewId" + - path: "$.parsed_properties.product_id" + target_name: "productId" + - path: "$.parsed_properties.rating" + target_name: "rating" + - name: promote_product_details + kind: promote + mappings: + - path: "$.parsed_properties.product_id" + target_name: "productId" + - path: "$.parsed_properties.product_name" + target_name: "productName" + - path: "$.parsed_properties.product_description" + target_name: "productDescription" + joins: + - id: REVIEW_TO_PRODUCT + keys: + - label: products + property: productId + - label: reviews + property: productId + query: > + MATCH + (r:reviews)-[:REVIEW_TO_PRODUCT]->(p:products) + WITH + p, avg(r.rating) as avgRating, count(r) as reviewCount + RETURN + p.productId AS product_id, + p.productName AS product_name, + p.productDescription AS product_description, + avgRating AS avg_rating, + reviewCount AS review_count \ No newline at end of file diff --git a/tutorial/dapr/drasi/reactions/post-dapr-pubsub.yaml b/tutorial/dapr/drasi/reactions/post-dapr-pubsub.yaml new file mode 100644 index 0000000..715b74e --- /dev/null +++ b/tutorial/dapr/drasi/reactions/post-dapr-pubsub.yaml @@ -0,0 +1,23 @@ +kind: Reaction +apiVersion: v1 +name: stock-notifications-publisher +spec: + kind: PostDaprPubSub + queries: + # Publish low stock events to the "low-stock-events" topic + low-stock-event-query: > + { + "pubsubName": "notifications-pubsub", + "topicName": "low-stock-events", + "format": "Unpacked", + "skipControlSignals": true + } + + # Publish critical stock events to the "critical-stock-events" topic + critical-stock-event-query: > + { + "pubsubName": "notifications-pubsub", + "topicName": "critical-stock-events", + "format": "Unpacked", + "skipControlSignals": true + } \ No newline at end of file diff --git a/tutorial/dapr/drasi/reactions/signalr-reaction.yaml b/tutorial/dapr/drasi/reactions/signalr-reaction.yaml new file mode 100644 index 0000000..7fd3fad --- /dev/null +++ b/tutorial/dapr/drasi/reactions/signalr-reaction.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Reaction +name: signalr +spec: + kind: SignalR + queries: + at-risk-orders-query: + delayed-gold-orders-query: \ No newline at end of file diff --git a/tutorial/dapr/drasi/reactions/sync-dapr-statestore.yaml b/tutorial/dapr/drasi/reactions/sync-dapr-statestore.yaml new file mode 100644 index 0000000..d40f088 --- /dev/null +++ b/tutorial/dapr/drasi/reactions/sync-dapr-statestore.yaml @@ -0,0 +1,7 @@ +kind: Reaction +apiVersion: v1 +name: maintain-product-catalogue +spec: + kind: SyncDaprStateStore + queries: + product-catalogue-query: '{"keyField": "product_id", "stateStoreName": "catalogue-store"}' \ No newline at end of file diff --git a/tutorial/dapr/drasi/sources/customers.yaml b/tutorial/dapr/drasi/sources/customers.yaml new file mode 100644 index 0000000..8c144a4 --- /dev/null +++ b/tutorial/dapr/drasi/sources/customers.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Source +name: customers-source +spec: + kind: PostgreSQL + properties: + host: customers-db.default.svc.cluster.local + port: 5432 + user: postgres + password: postgres + database: customersdb + ssl: false + tables: + - public.customers \ No newline at end of file diff --git a/tutorial/dapr/drasi/sources/orders.yaml b/tutorial/dapr/drasi/sources/orders.yaml new file mode 100644 index 0000000..e0f6bac --- /dev/null +++ b/tutorial/dapr/drasi/sources/orders.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Source +name: orders-source +spec: + kind: PostgreSQL + properties: + host: orders-db.default.svc.cluster.local + port: 5432 + user: postgres + password: postgres + database: ordersdb + ssl: false + tables: + - public.orders \ No newline at end of file diff --git a/tutorial/dapr/drasi/sources/products.yaml b/tutorial/dapr/drasi/sources/products.yaml new file mode 100644 index 0000000..987a41b --- /dev/null +++ b/tutorial/dapr/drasi/sources/products.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Source +name: products-source +spec: + kind: PostgreSQL + properties: + host: products-db.default.svc.cluster.local + port: 5432 + user: postgres + password: postgres + database: productsdb + ssl: false + tables: + - public.products \ No newline at end of file diff --git a/tutorial/dapr/drasi/sources/reviews.yaml b/tutorial/dapr/drasi/sources/reviews.yaml new file mode 100644 index 0000000..fc9e201 --- /dev/null +++ b/tutorial/dapr/drasi/sources/reviews.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Source +name: reviews-source +spec: + kind: PostgreSQL + properties: + host: reviews-db.default.svc.cluster.local + port: 5432 + user: postgres + password: postgres + database: reviewsdb + ssl: false + tables: + - public.reviews \ No newline at end of file diff --git a/tutorial/dapr/images/01-architectire-overview.png b/tutorial/dapr/images/01-architectire-overview.png new file mode 100644 index 0000000..e2f6b66 Binary files /dev/null and b/tutorial/dapr/images/01-architectire-overview.png differ diff --git a/tutorial/dapr/images/02-ecommerce-scenario.png b/tutorial/dapr/images/02-ecommerce-scenario.png new file mode 100644 index 0000000..2f8922b Binary files /dev/null and b/tutorial/dapr/images/02-ecommerce-scenario.png differ diff --git a/tutorial/dapr/images/03-derived-data-challenge.png b/tutorial/dapr/images/03-derived-data-challenge.png new file mode 100644 index 0000000..3fa8f06 Binary files /dev/null and b/tutorial/dapr/images/03-derived-data-challenge.png differ diff --git a/tutorial/dapr/images/04-dashboard-challenge.png b/tutorial/dapr/images/04-dashboard-challenge.png new file mode 100644 index 0000000..33c916d Binary files /dev/null and b/tutorial/dapr/images/04-dashboard-challenge.png differ diff --git a/tutorial/dapr/images/05-eventing-challenge.png b/tutorial/dapr/images/05-eventing-challenge.png new file mode 100644 index 0000000..76e9f10 Binary files /dev/null and b/tutorial/dapr/images/05-eventing-challenge.png differ diff --git a/tutorial/dapr/images/06-drasi-overview.png b/tutorial/dapr/images/06-drasi-overview.png new file mode 100644 index 0000000..53ce92b Binary files /dev/null and b/tutorial/dapr/images/06-drasi-overview.png differ diff --git a/tutorial/dapr/images/07-drasi-loves-dapr.png b/tutorial/dapr/images/07-drasi-loves-dapr.png new file mode 100644 index 0000000..22950c8 Binary files /dev/null and b/tutorial/dapr/images/07-drasi-loves-dapr.png differ diff --git a/tutorial/dapr/images/08-product-catalog-query.png b/tutorial/dapr/images/08-product-catalog-query.png new file mode 100644 index 0000000..6787b0c Binary files /dev/null and b/tutorial/dapr/images/08-product-catalog-query.png differ diff --git a/tutorial/dapr/images/09-dashboard-queries.png b/tutorial/dapr/images/09-dashboard-queries.png new file mode 100644 index 0000000..5aa4771 Binary files /dev/null and b/tutorial/dapr/images/09-dashboard-queries.png differ diff --git a/tutorial/dapr/images/10-inventory-alerts.png b/tutorial/dapr/images/10-inventory-alerts.png new file mode 100644 index 0000000..22ef7d0 Binary files /dev/null and b/tutorial/dapr/images/10-inventory-alerts.png differ diff --git a/tutorial/dapr/images/11-benefits-recap.png b/tutorial/dapr/images/11-benefits-recap.png new file mode 100644 index 0000000..e31f5a5 Binary files /dev/null and b/tutorial/dapr/images/11-benefits-recap.png differ diff --git a/tutorial/dapr/scripts/cleanup-tutorial.ps1 b/tutorial/dapr/scripts/cleanup-tutorial.ps1 new file mode 100644 index 0000000..cfb96c6 --- /dev/null +++ b/tutorial/dapr/scripts/cleanup-tutorial.ps1 @@ -0,0 +1,26 @@ +# Copyright 2025 The Drasi Authors. +# +# 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. + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Dapr + Drasi Tutorial Cleanup" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +# Delete k3d cluster +Write-Host "Deleting k3d cluster..." -ForegroundColor Yellow +k3d cluster delete drasi-tutorial 2>$null + +Write-Host "" +Write-Host "=========================================" -ForegroundColor Green +Write-Host "Cleanup Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green \ No newline at end of file diff --git a/tutorial/dapr/scripts/cleanup-tutorial.sh b/tutorial/dapr/scripts/cleanup-tutorial.sh new file mode 100755 index 0000000..afd6951 --- /dev/null +++ b/tutorial/dapr/scripts/cleanup-tutorial.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Copyright 2025 The Drasi Authors. +# +# 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. + +echo "=========================================" +echo "Dapr + Drasi Tutorial Cleanup" +echo "=========================================" + +# Delete k3d cluster +echo "Deleting k3d cluster..." +k3d cluster delete drasi-tutorial 2>/dev/null || true + +echo "" +echo "=========================================" +echo "Cleanup Complete!" +echo "=========================================" \ No newline at end of file diff --git a/tutorial/dapr/scripts/dev-reload.ps1 b/tutorial/dapr/scripts/dev-reload.ps1 new file mode 100644 index 0000000..79945d9 --- /dev/null +++ b/tutorial/dapr/scripts/dev-reload.ps1 @@ -0,0 +1,62 @@ +# Copyright 2025 The Drasi Authors. +# +# 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. + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Dapr + Drasi Tutorial - Dev Reload" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +Write-Host "This script reloads all deployments to pull the latest images" -ForegroundColor Yellow +Write-Host "" + +# Delete and recreate all deployments to force image pull +Write-Host "Reloading Products service..." -ForegroundColor Yellow +kubectl delete deployment products 2>$null +kubectl apply -f services/products/k8s/deployment.yaml + +Write-Host "Reloading Customers service..." -ForegroundColor Yellow +kubectl delete deployment customers 2>$null +kubectl apply -f services/customers/k8s/deployment.yaml + +Write-Host "Reloading Orders service..." -ForegroundColor Yellow +kubectl delete deployment orders 2>$null +kubectl apply -f services/orders/k8s/deployment.yaml + +Write-Host "Reloading Reviews service..." -ForegroundColor Yellow +kubectl delete deployment reviews 2>$null +kubectl apply -f services/reviews/k8s/deployment.yaml + +Write-Host "Reloading Catalogue service..." -ForegroundColor Yellow +kubectl delete deployment catalogue 2>$null +kubectl apply -f services/catalogue/k8s/deployment.yaml + +Write-Host "Reloading Dashboard service..." -ForegroundColor Yellow +kubectl delete deployment dashboard 2>$null +kubectl apply -f services/dashboard/k8s/deployment.yaml + +Write-Host "Reloading Notifications service..." -ForegroundColor Yellow +kubectl delete deployment notifications 2>$null +kubectl apply -f services/notifications/k8s/deployment.yaml + +Write-Host "Waiting for all deployments to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=available deployment --all --timeout=300s + +Write-Host "Waiting for all pods to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=Ready pod --all --timeout=300s + +Write-Host "" +Write-Host "=========================================" -ForegroundColor Green +Write-Host "Dev Reload Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green +Write-Host "" +Write-Host "All services have been reloaded with the latest images." -ForegroundColor White \ No newline at end of file diff --git a/tutorial/dapr/scripts/dev-reload.sh b/tutorial/dapr/scripts/dev-reload.sh new file mode 100644 index 0000000..753d51f --- /dev/null +++ b/tutorial/dapr/scripts/dev-reload.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# Copyright 2025 The Drasi Authors. +# +# 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. + +echo "=========================================" +echo "Dapr + Drasi Tutorial - Dev Reload" +echo "=========================================" + +echo "This script reloads all deployments to pull the latest images" +echo "" + +# Delete and recreate all deployments to force image pull +echo "Reloading Products service..." +kubectl delete deployment products 2>/dev/null || true +kubectl apply -f services/products/k8s/deployment.yaml + +echo "Reloading Customers service..." +kubectl delete deployment customers 2>/dev/null || true +kubectl apply -f services/customers/k8s/deployment.yaml + +echo "Reloading Orders service..." +kubectl delete deployment orders 2>/dev/null || true +kubectl apply -f services/orders/k8s/deployment.yaml + +echo "Reloading Reviews service..." +kubectl delete deployment reviews 2>/dev/null || true +kubectl apply -f services/reviews/k8s/deployment.yaml + +echo "Reloading Catalogue service..." +kubectl delete deployment catalogue 2>/dev/null || true +kubectl apply -f services/catalogue/k8s/deployment.yaml + +echo "Reloading Dashboard service..." +kubectl delete deployment dashboard 2>/dev/null || true +kubectl apply -f services/dashboard/k8s/deployment.yaml + +echo "Reloading Notifications service..." +kubectl delete deployment notifications 2>/dev/null || true +kubectl apply -f services/notifications/k8s/deployment.yaml + +echo "Waiting for all deployments to be ready..." +kubectl wait --for=condition=available deployment --all --timeout=300s + +echo "Waiting for all pods to be ready..." +kubectl wait --for=condition=Ready pod --all --timeout=300s + +echo "" +echo "=========================================" +echo "Dev Reload Complete!" +echo "=========================================" +echo "" +echo "All services have been reloaded with the latest images." \ No newline at end of file diff --git a/tutorial/dapr/scripts/reset-images.ps1 b/tutorial/dapr/scripts/reset-images.ps1 new file mode 100644 index 0000000..f15740f --- /dev/null +++ b/tutorial/dapr/scripts/reset-images.ps1 @@ -0,0 +1,44 @@ +# Copyright 2025 The Drasi Authors. +# +# 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. + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Dapr + Drasi Tutorial - Reset Images" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +Write-Host "This script forces a fresh pull of all Docker images" -ForegroundColor Yellow +Write-Host "" + +# Get all pods and delete them to force image repull +Write-Host "Deleting all pods to force fresh image pulls..." -ForegroundColor Yellow + +kubectl delete pod -l app=products --force --grace-period=0 2>$null +kubectl delete pod -l app=customers --force --grace-period=0 2>$null +kubectl delete pod -l app=orders --force --grace-period=0 2>$null +kubectl delete pod -l app=reviews --force --grace-period=0 2>$null +kubectl delete pod -l app=catalogue --force --grace-period=0 2>$null +kubectl delete pod -l app=dashboard --force --grace-period=0 2>$null +kubectl delete pod -l app=notifications --force --grace-period=0 2>$null + +Write-Host "Waiting for deployments to recreate pods..." -ForegroundColor Yellow +Start-Sleep -Seconds 10 + +Write-Host "Waiting for all pods to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=Ready pod --all --timeout=300s + +Write-Host "" +Write-Host "=========================================" -ForegroundColor Green +Write-Host "Image Reset Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green +Write-Host "" +Write-Host "All pods have been recreated with fresh image pulls." -ForegroundColor White \ No newline at end of file diff --git a/tutorial/dapr/scripts/reset-images.sh b/tutorial/dapr/scripts/reset-images.sh new file mode 100644 index 0000000..37485ca --- /dev/null +++ b/tutorial/dapr/scripts/reset-images.sh @@ -0,0 +1,45 @@ +#!/bin/bash +# Copyright 2025 The Drasi Authors. +# +# 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. + +echo "=========================================" +echo "Dapr + Drasi Tutorial - Reset Images" +echo "=========================================" + +echo "This script forces a fresh pull of all Docker images" +echo "" + +# Get all pods and delete them to force image repull +echo "Deleting all pods to force fresh image pulls..." + +kubectl delete pod -l app=products --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=customers --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=orders --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=reviews --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=catalogue --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=dashboard --force --grace-period=0 2>/dev/null || true +kubectl delete pod -l app=notifications --force --grace-period=0 2>/dev/null || true + +echo "Waiting for deployments to recreate pods..." +sleep 10 + +echo "Waiting for all pods to be ready..." +kubectl wait --for=condition=Ready pod --all --timeout=300s + +echo "" +echo "=========================================" +echo "Image Reset Complete!" +echo "=========================================" +echo "" +echo "All pods have been recreated with fresh image pulls." \ No newline at end of file diff --git a/tutorial/dapr/scripts/setup-tutorial.ps1 b/tutorial/dapr/scripts/setup-tutorial.ps1 new file mode 100644 index 0000000..eb146eb --- /dev/null +++ b/tutorial/dapr/scripts/setup-tutorial.ps1 @@ -0,0 +1,186 @@ +# Copyright 2025 The Drasi Authors. +# +# 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. + +Write-Host "=========================================" -ForegroundColor Cyan +Write-Host "Dapr + Drasi Tutorial Setup" -ForegroundColor Cyan +Write-Host "=========================================" -ForegroundColor Cyan + +# Define k3d version +$K3D_VERSION = "v5.6.0" + +# Check if we're in GitHub Codespaces +if ($env:CODESPACES) { + Write-Host "Running in GitHub Codespaces environment" -ForegroundColor Green + $PORT_MAPPING = "80:80@loadbalancer" + $BASE_URL = "http://localhost" +} else { + Write-Host "Running in local/DevContainer environment" -ForegroundColor Green + $PORT_MAPPING = "8123:80@loadbalancer" + $BASE_URL = "http://localhost:8123" +} + +Write-Host "Creating K3d cluster..." -ForegroundColor Yellow +# Delete existing cluster if it exists +k3d cluster delete drasi-tutorial 2>$null +k3d cluster create drasi-tutorial --port $PORT_MAPPING + +Write-Host "Waiting for cluster to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=ready node --all --timeout=60s + +Write-Host "Waiting for Traefik to be ready..." -ForegroundColor Yellow +$MAX_JOB_WAIT = 30 +$JOB_WAITED = 0 +while ($JOB_WAITED -lt $MAX_JOB_WAIT) { + if (kubectl get job -n kube-system helm-install-traefik 2>$null) { + Write-Host "Traefik Helm job found, waiting for completion..." -ForegroundColor Yellow + kubectl wait --for=condition=complete job/helm-install-traefik -n kube-system --timeout=300s + break + } + Write-Host "Waiting for Traefik Helm job to appear..." -ForegroundColor Yellow + Start-Sleep -Seconds 2 + $JOB_WAITED += 2 +} + +Write-Host "Initializing Drasi (this will also install Dapr)..." -ForegroundColor Yellow + +# Configure Drasi to use kubectl context +Write-Host "Configuring Drasi to use kubectl context..." -ForegroundColor Yellow +drasi env kube 2>$null + +$MAX_ATTEMPTS = 3 +$ATTEMPT = 1 +$DRASI_INITIALIZED = $false + +while (($ATTEMPT -le $MAX_ATTEMPTS) -and (-not $DRASI_INITIALIZED)) { + Write-Host "Drasi initialization attempt $ATTEMPT of $MAX_ATTEMPTS..." -ForegroundColor Yellow + + if (drasi init) { + $DRASI_INITIALIZED = $true + Write-Host "Drasi initialized successfully!" -ForegroundColor Green + } else { + Write-Host "Drasi initialization failed." -ForegroundColor Red + + if ($ATTEMPT -lt $MAX_ATTEMPTS) { + Write-Host "Uninstalling Drasi before retry..." -ForegroundColor Yellow + drasi uninstall -y 2>$null + Start-Sleep -Seconds 5 + } else { + Write-Host "ERROR: Failed to initialize Drasi after $MAX_ATTEMPTS attempts." -ForegroundColor Red + exit 1 + } + } + + $ATTEMPT++ +} + +Write-Host "Deploying PostgreSQL databases..." -ForegroundColor Yellow +kubectl apply -f services/products/k8s/postgres/postgres.yaml +kubectl apply -f services/customers/k8s/postgres/postgres.yaml +kubectl apply -f services/orders/k8s/postgres/postgres.yaml +kubectl apply -f services/reviews/k8s/postgres/postgres.yaml +kubectl apply -f services/catalogue/k8s/postgres/postgres.yaml + +Write-Host "Waiting for PostgreSQL databases to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=ready pod -l app=products-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=customers-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=orders-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=reviews-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=catalogue-db --timeout=120s + +Write-Host "Deploying Redis for notifications..." -ForegroundColor Yellow +kubectl apply -f services/notifications/k8s/redis/redis.yaml + +Write-Host "Waiting for Redis to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=ready pod -l app=notifications-redis --timeout=120s + +Write-Host "Deploying Dapr components..." -ForegroundColor Yellow +kubectl apply -f services/products/k8s/dapr/statestore.yaml +kubectl apply -f services/customers/k8s/dapr/statestore.yaml +kubectl apply -f services/orders/k8s/dapr/statestore.yaml +kubectl apply -f services/reviews/k8s/dapr/statestore.yaml +kubectl apply -f services/catalogue/k8s/dapr/statestore.yaml +kubectl apply -f services/catalogue/k8s/dapr/statestore-drasi.yaml +kubectl apply -f services/notifications/k8s/dapr/pubsub.yaml +kubectl apply -f services/notifications/k8s/dapr/pubsub-drasi.yaml + +Write-Host "Deploying applications..." -ForegroundColor Yellow +kubectl apply -f services/products/k8s/deployment.yaml +kubectl apply -f services/customers/k8s/deployment.yaml +kubectl apply -f services/orders/k8s/deployment.yaml +kubectl apply -f services/reviews/k8s/deployment.yaml +kubectl apply -f services/catalogue/k8s/deployment.yaml +kubectl apply -f services/dashboard/k8s/deployment.yaml +kubectl apply -f services/notifications/k8s/deployment.yaml + +Write-Host "Deploying SignalR ingress..." -ForegroundColor Yellow +kubectl apply -f services/dashboard/k8s/signalr-ingress.yaml + +Write-Host "Waiting for all deployments to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=available deployment --all --timeout=300s + +Write-Host "Waiting for all pods to be ready..." -ForegroundColor Yellow +kubectl wait --for=condition=Ready pod --all --timeout=300s + +Write-Host "Loading initial data into services..." -ForegroundColor Yellow +Write-Host "Loading products data..." -ForegroundColor Yellow +bash services/products/setup/load-initial-data.sh "$BASE_URL/products-service" + +Write-Host "Loading customers data..." -ForegroundColor Yellow +bash services/customers/setup/load-initial-data.sh "$BASE_URL/customers-service" + +Write-Host "Loading orders data..." -ForegroundColor Yellow +bash services/orders/setup/load-initial-data.sh "$BASE_URL/orders-service" + +Write-Host "Loading reviews data..." -ForegroundColor Yellow +bash services/reviews/setup/load-initial-data.sh "$BASE_URL/reviews-service" + +Write-Host "" +Write-Host "=========================================" -ForegroundColor Green +Write-Host "Setup Complete!" -ForegroundColor Green +Write-Host "=========================================" -ForegroundColor Green +Write-Host "" +if ($env:CODESPACES) { + Write-Host "Applications are available at:" -ForegroundColor Cyan + Write-Host " Catalog UI: https:///catalogue-service" -ForegroundColor White + Write-Host " Dashboard UI: https:///dashboard" -ForegroundColor White + Write-Host " Notifications UI: https:///notifications-service" -ForegroundColor White + Write-Host "" + Write-Host " API Endpoints:" -ForegroundColor Cyan + Write-Host " Products: https:///products-service/products" -ForegroundColor White + Write-Host " Customers: https:///customers-service/customers" -ForegroundColor White + Write-Host " Orders: https:///orders-service/orders" -ForegroundColor White + Write-Host " Reviews: https:///reviews-service/reviews" -ForegroundColor White +} else { + Write-Host "Applications are available at:" -ForegroundColor Cyan + Write-Host " Catalog UI: http://localhost:8123/catalogue-service" -ForegroundColor White + Write-Host " Dashboard UI: http://localhost:8123/dashboard" -ForegroundColor White + Write-Host " Notifications UI: http://localhost:8123/notifications-service" -ForegroundColor White + Write-Host "" + Write-Host " API Endpoints:" -ForegroundColor Cyan + Write-Host " Products: http://localhost:8123/products-service/products" -ForegroundColor White + Write-Host " Customers: http://localhost:8123/customers-service/customers" -ForegroundColor White + Write-Host " Orders: http://localhost:8123/orders-service/orders" -ForegroundColor White + Write-Host " Reviews: http://localhost:8123/reviews-service/reviews" -ForegroundColor White +} +Write-Host "" +Write-Host "To deploy Drasi components, run:" -ForegroundColor Yellow +Write-Host " kubectl apply -f drasi/sources/" -ForegroundColor White +Write-Host " kubectl apply -f drasi/queries/" -ForegroundColor White +Write-Host " kubectl apply -f drasi/reactions/" -ForegroundColor White +Write-Host "" +Write-Host "Then explore the demos:" -ForegroundColor Yellow +Write-Host " cd demo" -ForegroundColor White +Write-Host " ./demo-catalogue-service.sh" -ForegroundColor White +Write-Host " ./demo-dashboard-service.sh" -ForegroundColor White +Write-Host " ./demo-notifications-service.sh" -ForegroundColor White \ No newline at end of file diff --git a/tutorial/dapr/scripts/setup-tutorial.sh b/tutorial/dapr/scripts/setup-tutorial.sh new file mode 100755 index 0000000..49fc0ec --- /dev/null +++ b/tutorial/dapr/scripts/setup-tutorial.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# Copyright 2025 The Drasi Authors. +# +# 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. + +set -e + +echo "=========================================" +echo "Dapr + Drasi Tutorial Setup" +echo "=========================================" + +# Define k3d version to use across all environments +K3D_VERSION="v5.6.0" + +# Check if we're in GitHub Codespaces +if [ -n "$CODESPACES" ]; then + echo "Running in GitHub Codespaces environment" + PORT_MAPPING="80:80@loadbalancer" + BASE_URL="http://localhost" +else + echo "Running in local/DevContainer environment" + PORT_MAPPING="8123:80@loadbalancer" + BASE_URL="http://localhost:8123" +fi + +echo "Creating K3d cluster..." +# Delete existing cluster if it exists +k3d cluster delete drasi-tutorial 2>/dev/null || true +k3d cluster create drasi-tutorial --port "$PORT_MAPPING" + +echo "Waiting for cluster to be ready..." +kubectl wait --for=condition=ready node --all --timeout=60s + +echo "Waiting for Traefik to be ready..." +# Wait for Traefik Helm job to complete +echo "Waiting for Traefik Helm installation..." +MAX_JOB_WAIT=30 +JOB_WAITED=0 +while [ $JOB_WAITED -lt $MAX_JOB_WAIT ]; do + if kubectl get job -n kube-system helm-install-traefik >/dev/null 2>&1; then + echo "Traefik Helm job found, waiting for completion..." + kubectl wait --for=condition=complete job/helm-install-traefik -n kube-system --timeout=300s || { + echo "Warning: Traefik Helm job didn't complete in time, continuing anyway..." + } + break + fi + echo "Waiting for Traefik Helm job to appear..." + sleep 2 + JOB_WAITED=$((JOB_WAITED + 2)) +done + +if [ $JOB_WAITED -ge $MAX_JOB_WAIT ]; then + echo "Warning: Traefik Helm job not found after ${MAX_JOB_WAIT}s, continuing anyway..." +fi + +# Wait for Traefik CRDs to be available +echo "Waiting for Traefik CRDs..." +MAX_CRD_WAIT=60 +CRD_WAITED=0 +while [ $CRD_WAITED -lt $MAX_CRD_WAIT ]; do + # Check for both traefik.containo.us and traefik.io CRDs + if kubectl get crd middlewares.traefik.containo.us >/dev/null 2>&1 && \ + kubectl get crd middlewares.traefik.io >/dev/null 2>&1 && \ + kubectl get crd ingressroutes.traefik.io >/dev/null 2>&1; then + echo "All Traefik CRDs are ready!" + break + fi + echo "Waiting for Traefik CRDs to be created..." + sleep 2 + CRD_WAITED=$((CRD_WAITED + 2)) +done + +if [ $CRD_WAITED -ge $MAX_CRD_WAIT ]; then + echo "Warning: Traefik CRDs not found after ${MAX_CRD_WAIT}s, continuing anyway..." +fi + +echo "Initializing Drasi (this will also install Dapr)..." + +# Configure Drasi to use kubectl context +echo "Configuring Drasi to use kubectl context..." +if drasi env kube 2>/dev/null; then + echo "Drasi configured to use kubectl context" +else + echo "WARNING: Failed to configure Drasi environment with 'drasi env kube'" + echo "Continuing with initialization anyway..." +fi + +MAX_ATTEMPTS=3 +ATTEMPT=1 +DRASI_INITIALIZED=false + +while [ $ATTEMPT -le $MAX_ATTEMPTS ] && [ "$DRASI_INITIALIZED" = "false" ]; do + echo "Drasi initialization attempt $ATTEMPT of $MAX_ATTEMPTS..." + + if drasi init; then + DRASI_INITIALIZED=true + echo "Drasi initialized successfully!" + else + echo "Drasi initialization failed." + + if [ $ATTEMPT -lt $MAX_ATTEMPTS ]; then + echo "Uninstalling Drasi before retry..." + drasi uninstall -y 2>/dev/null || true + sleep 5 + else + echo "ERROR: Failed to initialize Drasi after $MAX_ATTEMPTS attempts." + exit 1 + fi + fi + + ATTEMPT=$((ATTEMPT + 1)) +done + +echo "Deploying PostgreSQL databases..." +kubectl apply -f services/products/k8s/postgres/postgres.yaml +kubectl apply -f services/customers/k8s/postgres/postgres.yaml +kubectl apply -f services/orders/k8s/postgres/postgres.yaml +kubectl apply -f services/reviews/k8s/postgres/postgres.yaml +kubectl apply -f services/catalogue/k8s/postgres/postgres.yaml + +echo "Waiting for PostgreSQL databases to be ready..." +kubectl wait --for=condition=ready pod -l app=products-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=customers-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=orders-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=reviews-db --timeout=120s +kubectl wait --for=condition=ready pod -l app=catalogue-db --timeout=120s + +echo "Deploying Redis for notifications..." +kubectl apply -f services/notifications/k8s/redis/redis.yaml + +echo "Waiting for Redis to be ready..." +kubectl wait --for=condition=ready pod -l app=notifications-redis --timeout=120s + +echo "Deploying Dapr components..." +kubectl apply -f services/products/k8s/dapr/statestore.yaml +kubectl apply -f services/customers/k8s/dapr/statestore.yaml +kubectl apply -f services/orders/k8s/dapr/statestore.yaml +kubectl apply -f services/reviews/k8s/dapr/statestore.yaml +kubectl apply -f services/catalogue/k8s/dapr/statestore.yaml +kubectl apply -f services/catalogue/k8s/dapr/statestore-drasi.yaml +kubectl apply -f services/notifications/k8s/dapr/pubsub.yaml +kubectl apply -f services/notifications/k8s/dapr/pubsub-drasi.yaml + +echo "Deploying applications..." +kubectl apply -f services/products/k8s/deployment.yaml +kubectl apply -f services/customers/k8s/deployment.yaml +kubectl apply -f services/orders/k8s/deployment.yaml +kubectl apply -f services/reviews/k8s/deployment.yaml +kubectl apply -f services/catalogue/k8s/deployment.yaml +kubectl apply -f services/dashboard/k8s/deployment.yaml +kubectl apply -f services/notifications/k8s/deployment.yaml + +echo "Deploying SignalR ingress..." +kubectl apply -f services/dashboard/k8s/signalr-ingress.yaml + +echo "Waiting for all deployments to be ready..." +kubectl wait --for=condition=available deployment --all --timeout=300s + +echo "Waiting for all pods to be ready..." +kubectl wait --for=condition=Ready pod --all --timeout=300s + +echo "Loading initial data into services..." +echo "Loading products data..." +bash services/products/setup/load-initial-data.sh "$BASE_URL/products-service" + +echo "Loading customers data..." +bash services/customers/setup/load-initial-data.sh "$BASE_URL/customers-service" + +echo "Loading orders data..." +bash services/orders/setup/load-initial-data.sh "$BASE_URL/orders-service" + +echo "Loading reviews data..." +bash services/reviews/setup/load-initial-data.sh "$BASE_URL/reviews-service" + +echo "" +echo "=========================================" +echo "Setup Complete!" +echo "=========================================" +echo "" +if [ -n "$CODESPACES" ]; then + echo "Applications are available at:" + echo " Catalog UI: https:///catalogue-service" + echo " Dashboard UI: https:///dashboard" + echo " Notifications UI: https:///notifications-service" + echo "" + echo " API Endpoints:" + echo " Products: https:///products-service/products" + echo " Customers: https:///customers-service/customers" + echo " Orders: https:///orders-service/orders" + echo " Reviews: https:///reviews-service/reviews" +else + echo "Applications are available at:" + echo " Catalog UI: http://localhost:8123/catalogue-service" + echo " Dashboard UI: http://localhost:8123/dashboard" + echo " Notifications UI: http://localhost:8123/notifications-service" + echo "" + echo " API Endpoints:" + echo " Products: http://localhost:8123/products-service/products" + echo " Customers: http://localhost:8123/customers-service/customers" + echo " Orders: http://localhost:8123/orders-service/orders" + echo " Reviews: http://localhost:8123/reviews-service/reviews" +fi +echo "" +echo "To deploy Drasi components, run:" +echo " kubectl apply -f drasi/sources/" +echo " kubectl apply -f drasi/queries/" +echo " kubectl apply -f drasi/reactions/" +echo "" +echo "Then explore the demos:" +echo " cd demo" +echo " ./demo-catalogue-service.sh" +echo " ./demo-dashboard-service.sh" +echo " ./demo-notifications-service.sh" \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/Dockerfile b/tutorial/dapr/services/catalogue/Dockerfile new file mode 100644 index 0000000..f318080 --- /dev/null +++ b/tutorial/dapr/services/catalogue/Dockerfile @@ -0,0 +1,53 @@ +# Stage 1: Build React app +FROM node:18-alpine as frontend-builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm install + +# Copy source files and build +COPY tsconfig*.json ./ +COPY vite.config.ts ./ +COPY tailwind.config.js ./ +COPY postcss.config.js ./ +COPY index.html ./ +COPY src/ ./src/ + +RUN npm run build + +# Stage 2: Python backend +FROM python:3.11-slim + +WORKDIR /app + +# Install nginx and supervisor +RUN apt-get update && apt-get install -y \ + nginx \ + supervisor \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy Python requirements and install +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy Python application code +COPY code/ ./code/ + +# Copy React build from previous stage +COPY --from=frontend-builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/sites-available/default + +# Copy supervisor configuration +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose port +EXPOSE 80 + +# Run supervisor to manage both nginx and uvicorn +CMD ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/code/dapr_client.py b/tutorial/dapr/services/catalogue/code/dapr_client.py new file mode 100644 index 0000000..34e4641 --- /dev/null +++ b/tutorial/dapr/services/catalogue/code/dapr_client.py @@ -0,0 +1,133 @@ +import json +import logging +import os +import base64 +from typing import Optional, Any, List, Dict +from dapr.clients import DaprClient + +logger = logging.getLogger(__name__) + + +class DaprStateStore: + def __init__(self, store_name: Optional[str] = None): + self.store_name = store_name or os.getenv("DAPR_STORE_NAME", "catalogue-store") + self.client = DaprClient() + logger.info(f"Initialized Dapr state store client for store: {self.store_name}") + + async def get_item(self, key: str) -> Optional[dict]: + """Get an item from the state store.""" + try: + response = self.client.get_state( + store_name=self.store_name, + key=key + ) + + if response.data: + data = json.loads(response.data) + logger.debug(f"Retrieved item with key '{key}': {data}") + return data + else: + logger.debug(f"No item found with key '{key}'") + return None + + except Exception as e: + logger.error(f"Error getting item with key '{key}': {str(e)}") + raise + + async def save_item(self, key: str, data: dict) -> None: + """Save an item to the state store.""" + try: + self.client.save_state( + store_name=self.store_name, + key=key, + value=json.dumps(data) + ) + logger.debug(f"Saved item with key '{key}': {data}") + + except Exception as e: + logger.error(f"Error saving item with key '{key}': {str(e)}") + raise + + async def delete_item(self, key: str) -> None: + """Delete an item from the state store.""" + try: + self.client.delete_state( + store_name=self.store_name, + key=key + ) + logger.debug(f"Deleted item with key '{key}'") + + except Exception as e: + logger.error(f"Error deleting item with key '{key}': {str(e)}") + raise + + async def query_items(self, query: Dict[str, Any]) -> tuple[List[Dict[str, Any]], Optional[str]]: + """ + Query items from the state store using Dapr state query API. + + Args: + query: Query dictionary with filter, sort, and page options + + Returns: + Tuple of (results list, pagination token) + """ + try: + query_json = json.dumps(query) + logger.info(f"Executing state query with: {query_json}") + response = self.client.query_state( + store_name=self.store_name, + query=query_json + ) + + logger.info(f"Query response type: {type(response)}, has {len(response.results)} results") + + results = [] + for item in response.results: + logger.info(f"Processing item - key: {item.key}, value type: {type(item.value)}") + try: + # The value might already be a string (JSON), not bytes + if hasattr(item.value, 'decode'): + # It's bytes, decode it + value_str = item.value.decode('UTF-8') + logger.info(f"Decoded bytes to string for key {item.key}") + else: + # It's already a string + value_str = item.value + logger.info(f"Value already string for key {item.key}") + + logger.info(f"Value string for key {item.key}: {value_str[:200]}...") # First 200 chars + + # Parse the JSON string + value = json.loads(value_str) + logger.info(f"First JSON parse result type for key {item.key}: {type(value)}") + + # If the value is a string, it might be base64 encoded JSON + if isinstance(value, str): + logger.info(f"Value is a string, checking if it's base64 encoded for key {item.key}") + try: + # Try base64 decoding + decoded_bytes = base64.b64decode(value) + decoded_str = decoded_bytes.decode('utf-8') + logger.info(f"Base64 decoded string for key {item.key}: {decoded_str[:200]}...") + value = json.loads(decoded_str) + logger.info(f"Successfully parsed base64-decoded JSON for key {item.key}") + except Exception as e: + logger.warning(f"Failed to base64 decode for key {item.key}: {e}") + # Keep the original string value + + logger.info(f"Final value type for key {item.key}: {type(value)}") + + results.append({ + 'key': item.key, + 'value': value + }) + except Exception as e: + logger.error(f"Failed to parse item with key {item.key}: {e}") + logger.error(f"Raw value type: {type(item.value)}, content: {item.value}") + + logger.info(f"Query completed - returned {len(results)} valid items, token: {response.token}") + return results, response.token + + except Exception as e: + logger.error(f"Error querying state store: {str(e)}") + raise \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/code/main.py b/tutorial/dapr/services/catalogue/code/main.py new file mode 100644 index 0000000..578867b --- /dev/null +++ b/tutorial/dapr/services/catalogue/code/main.py @@ -0,0 +1,172 @@ +import logging +import os +import time +from contextlib import asynccontextmanager +from typing import Optional, List + +from fastapi import FastAPI, HTTPException, Depends, status, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from models import CatalogueItem, CatalogueResponse, CatalogueListResponse +from dapr_client import DaprStateStore + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global state store instance +state_store = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global state_store + state_store = DaprStateStore() + logger.info("Catalogue service started") + yield + # Shutdown + logger.info("Catalogue service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Catalogue Service", + description="Read-only service for product catalogue data populated by Drasi", + version="1.0.0", + lifespan=lifespan, + root_path="/catalogue-service", + docs_url="/api/docs", + redoc_url="/api/redoc", + openapi_url="/api/openapi.json" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_state_store() -> DaprStateStore: + """Dependency to get the state store instance.""" + if state_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="State store not initialized" + ) + return state_store + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "catalogue"} + + +@app.get("/api/catalogue/{product_id}", response_model=CatalogueResponse) +async def get_product_catalogue( + product_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Get catalogue information for a specific product.""" + start_time = time.time() + + try: + # Drasi uses the productId as the key in the state store + data = await store.get_item(str(product_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product {product_id} not found in catalogue" + ) + + catalogue_item = CatalogueItem.from_db_dict(data) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved catalogue data for product {product_id} in {elapsed:.2f}ms") + + return CatalogueResponse.from_catalogue_item(catalogue_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving catalogue data: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve catalogue data: {str(e)}" + ) + + +@app.get("/api/catalogue", response_model=CatalogueListResponse) +async def list_catalogue_items( + store: DaprStateStore = Depends(get_state_store) +): + """ + Get all catalogue items using Dapr state query API with an empty filter. + """ + start_time = time.time() + + try: + # Simple query with empty filter to get all items + query = { + "filter": {} + } + + # Execute the query + results, _ = await store.query_items(query) + + # Convert results to CatalogueResponse objects + items = [] + for result in results: + try: + catalogue_item = CatalogueItem.from_db_dict(result['value']) + items.append(CatalogueResponse.from_catalogue_item(catalogue_item)) + except Exception as e: + logger.warning(f"Failed to parse item with key {result['key']}: {str(e)}") + continue + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved {len(items)} catalogue items in {elapsed:.2f}ms") + + return CatalogueListResponse( + items=items, + total=len(items) + ) + + except Exception as e: + logger.error(f"Error listing catalogue items: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list catalogue items: {str(e)}" + ) + + +@app.get("/api") +async def root(): + """Root endpoint with service information.""" + return { + "service": "catalogue", + "version": "1.0.0", + "description": "Read-only service for product catalogue data populated by Drasi", + "endpoints": { + "health": "/api/health", + "get_product": "/api/catalogue/{product_id}", + "list_products": "/api/catalogue", + "docs": "/api/docs", + "redoc": "/api/redoc" + } + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/code/models.py b/tutorial/dapr/services/catalogue/code/models.py new file mode 100644 index 0000000..0f89580 --- /dev/null +++ b/tutorial/dapr/services/catalogue/code/models.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel, Field +from typing import Optional + + +class CatalogueItem(BaseModel): + productId: int = Field(..., description="Unique product identifier") + productName: str = Field(..., description="Name of the product") + productDescription: str = Field(..., description="Description of the product") + avgRating: float = Field(..., ge=1.0, le=5.0, description="Average customer rating") + reviewCount: int = Field(..., ge=0, description="Total number of reviews") + + @classmethod + def from_db_dict(cls, data: dict) -> "CatalogueItem": + """Create from database format with snake_case (as stored by Drasi).""" + return cls( + productId=data["product_id"], + productName=data["product_name"], + productDescription=data["product_description"], + avgRating=data["avg_rating"], + reviewCount=data["review_count"] + ) + + +class CatalogueResponse(BaseModel): + productId: int + productName: str + productDescription: str + avgRating: float + reviewCount: int + + @staticmethod + def from_catalogue_item(item: CatalogueItem) -> "CatalogueResponse": + return CatalogueResponse( + productId=item.productId, + productName=item.productName, + productDescription=item.productDescription, + avgRating=round(item.avgRating, 2), + reviewCount=item.reviewCount + ) + + +class CatalogueListResponse(BaseModel): + items: list[CatalogueResponse] + total: int \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/docker-entrypoint.sh b/tutorial/dapr/services/catalogue/docker-entrypoint.sh new file mode 100644 index 0000000..7085a9d --- /dev/null +++ b/tutorial/dapr/services/catalogue/docker-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Start nginx +nginx -g 'daemon off;' & + +# Start FastAPI app +cd /app/code && uvicorn main:app --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/index.html b/tutorial/dapr/services/catalogue/index.html new file mode 100644 index 0000000..050fed3 --- /dev/null +++ b/tutorial/dapr/services/catalogue/index.html @@ -0,0 +1,13 @@ + + + + + + + Product Catalogue - Drasi Demo + + +
+ + + \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/k8s/dapr/statestore-drasi.yaml b/tutorial/dapr/services/catalogue/k8s/dapr/statestore-drasi.yaml new file mode 100644 index 0000000..77c776f --- /dev/null +++ b/tutorial/dapr/services/catalogue/k8s/dapr/statestore-drasi.yaml @@ -0,0 +1,19 @@ +# This component is for Drasi to access the catalogue state store from drasi-system namespace +# IMPORTANT: This must have the same configuration as statestore.yaml but with namespace: drasi-system +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: catalogue-store + namespace: drasi-system # CRITICAL: Must be in drasi-system for Drasi reaction to access +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=catalogue-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=cataloguedb sslmode=disable" + - name: tableName + value: "catalogue" + - name: keyPrefix + value: "none" # CRITICAL: Must be "none" to ensure keys match between Drasi and the service + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/k8s/dapr/statestore.yaml b/tutorial/dapr/services/catalogue/k8s/dapr/statestore.yaml new file mode 100644 index 0000000..4d2524a --- /dev/null +++ b/tutorial/dapr/services/catalogue/k8s/dapr/statestore.yaml @@ -0,0 +1,17 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: catalogue-store + namespace: default +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=catalogue-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=cataloguedb sslmode=disable" + - name: tableName + value: "catalogue" + - name: keyPrefix + value: "none" + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/k8s/deployment.yaml b/tutorial/dapr/services/catalogue/k8s/deployment.yaml new file mode 100644 index 0000000..636e30c --- /dev/null +++ b/tutorial/dapr/services/catalogue/k8s/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: catalogue + labels: + app: catalogue +spec: + replicas: 1 + selector: + matchLabels: + app: catalogue + template: + metadata: + labels: + app: catalogue + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "catalogue" + dapr.io/app-port: "80" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: catalogue + image: ghcr.io/drasi-project/learning/dapr/catalogue-service:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + name: http + env: + - name: DAPR_STORE_NAME + value: "catalogue-store" + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 80 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: catalogue + labels: + app: catalogue +spec: + selector: + app: catalogue + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: catalogue-stripprefix +spec: + stripPrefix: + prefixes: + - /catalogue-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: catalogue + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-catalogue-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /catalogue-service + pathType: Prefix + backend: + service: + name: catalogue + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/k8s/postgres/postgres.yaml b/tutorial/dapr/services/catalogue/k8s/postgres/postgres.yaml new file mode 100644 index 0000000..be336f9 --- /dev/null +++ b/tutorial/dapr/services/catalogue/k8s/postgres/postgres.yaml @@ -0,0 +1,61 @@ +apiVersion: v1 +kind: Service +metadata: + name: catalogue-db + labels: + app: catalogue-db +spec: + ports: + - port: 5432 + name: postgres + selector: + app: catalogue-db +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: catalogue-db +spec: + serviceName: catalogue-db + replicas: 1 + selector: + matchLabels: + app: catalogue-db + template: + metadata: + labels: + app: catalogue-db + spec: + containers: + - name: postgres + image: postgres:14-alpine + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_PASSWORD + value: postgres + - name: POSTGRES_DB + value: cataloguedb + - name: PGDATA + value: /var/lib/postgresql/data/pgdata + volumeMounts: + - name: catalogue-db-data + mountPath: /var/lib/postgresql/data + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" + volumeClaimTemplates: + - metadata: + name: catalogue-db-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/nginx.conf b/tutorial/dapr/services/catalogue/nginx.conf new file mode 100644 index 0000000..3a423fd --- /dev/null +++ b/tutorial/dapr/services/catalogue/nginx.conf @@ -0,0 +1,35 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Handle the base path for catalogue-service + location /catalogue-service { + alias /usr/share/nginx/html; + try_files $uri $uri/ /catalogue-service/index.html; + } + + # Serve static files and assets + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to FastAPI backend + location /api { + proxy_pass http://localhost:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Health check endpoint + location /health { + proxy_pass http://localhost:8000/api/health; + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/package-lock.json b/tutorial/dapr/services/catalogue/package-lock.json new file mode 100644 index 0000000..c77fc5c --- /dev/null +++ b/tutorial/dapr/services/catalogue/package-lock.json @@ -0,0 +1,3365 @@ +{ + "name": "catalogue-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "catalogue-ui", + "version": "0.0.0", + "dependencies": { + "axios": "^1.6.2", + "lucide-react": "^0.294.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "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/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz", + "integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz", + "integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz", + "integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz", + "integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz", + "integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz", + "integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz", + "integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz", + "integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz", + "integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz", + "integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz", + "integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz", + "integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz", + "integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz", + "integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz", + "integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz", + "integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz", + "integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz", + "integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz", + "integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz", + "integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==", + "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.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@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 || ^7.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "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.30001733", + "electron-to-chromium": "^1.5.199", + "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/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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 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/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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "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==", + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.204", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.204.tgz", + "integrity": "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/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/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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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==", + "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/lucide-react": { + "version": "0.294.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.294.0.tgz", + "integrity": "sha512-V7o0/VECSGbLHn3/1O67FUgBwWB+hmzshrgDVRJQhMh8uj5D3HBuIvhuAmQTtlupILSplwIZg5FTc4tTKMA2SA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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/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/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.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==", + "dev": true, + "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/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.46.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz", + "integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.3", + "@rollup/rollup-android-arm64": "4.46.3", + "@rollup/rollup-darwin-arm64": "4.46.3", + "@rollup/rollup-darwin-x64": "4.46.3", + "@rollup/rollup-freebsd-arm64": "4.46.3", + "@rollup/rollup-freebsd-x64": "4.46.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", + "@rollup/rollup-linux-arm-musleabihf": "4.46.3", + "@rollup/rollup-linux-arm64-gnu": "4.46.3", + "@rollup/rollup-linux-arm64-musl": "4.46.3", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", + "@rollup/rollup-linux-ppc64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-musl": "4.46.3", + "@rollup/rollup-linux-s390x-gnu": "4.46.3", + "@rollup/rollup-linux-x64-gnu": "4.46.3", + "@rollup/rollup-linux-x64-musl": "4.46.3", + "@rollup/rollup-win32-arm64-msvc": "4.46.3", + "@rollup/rollup-win32-ia32-msvc": "4.46.3", + "@rollup/rollup-win32-x64-msvc": "4.46.3", + "fsevents": "~2.3.2" + } + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/tutorial/dapr/services/catalogue/package.json b/tutorial/dapr/services/catalogue/package.json new file mode 100644 index 0000000..a6dacda --- /dev/null +++ b/tutorial/dapr/services/catalogue/package.json @@ -0,0 +1,28 @@ +{ + "name": "catalogue-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.2", + "lucide-react": "^0.294.0" + }, + "devDependencies": { + "@types/react": "^18.2.37", + "@types/react-dom": "^18.2.15", + "@vitejs/plugin-react": "^4.2.0", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.2.2", + "vite": "^5.0.0" + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/postcss.config.js b/tutorial/dapr/services/catalogue/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/tutorial/dapr/services/catalogue/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/requirements.txt b/tutorial/dapr/services/catalogue/requirements.txt new file mode 100644 index 0000000..89e6c0a --- /dev/null +++ b/tutorial/dapr/services/catalogue/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +dapr==1.15.0 +httpx==0.25.2 \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/setup/test-apis.sh b/tutorial/dapr/services/catalogue/setup/test-apis.sh new file mode 100755 index 0000000..427ff5a --- /dev/null +++ b/tutorial/dapr/services/catalogue/setup/test-apis.sh @@ -0,0 +1,100 @@ +#!/bin/bash + +# Script to perform sanity check on Catalogue Service APIs +# Usage: ./test-apis.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/catalogue-service}" + +echo "Catalogue Service API Sanity Check" +echo "=================================" +echo "Base URL: $BASE_URL" +echo "" + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to print test result with counter +print_result() { + local test_name="$1" + local success="$2" + local message="$3" + + print_test_result "$test_name" "$success" "$message" + + if [ "$success" = "true" ]; then + ((TESTS_PASSED++)) + else + ((TESTS_FAILED++)) + fi +} + +echo "" +echo "Testing Catalogue Service APIs" +echo "==============================" + +# Test 1: Health Check +echo "" +echo "Test 1: Health Check" +echo "-------------------" +RESPONSE=$(make_request_with_retry "GET" "$BASE_URL/health" "") +if echo "$RESPONSE" | grep -q '"status":"healthy"'; then + print_result "Health check" true "Service is healthy" +else + print_result "Health check" false "Service health check failed" +fi + +# Test 2: List All Catalogue Items +echo "" +echo "Test 2: List All Catalogue Items" +echo "--------------------------------" +RESPONSE=$(make_request_with_retry "GET" "$BASE_URL/catalogue" "") +HTTP_CODE=$(echo "$RESPONSE" | tail -n 1) +if [ "$HTTP_CODE" = "200" ]; then + print_result "List all items" true "Successfully retrieved catalogue list" +else + print_result "List all items" false "Expected 200, got: $HTTP_CODE" +fi + +# Test 3: Get Product Catalogue (Product 1001) +echo "" +echo "Test 3: Get Product Catalogue - Product 1001" +echo "--------------------------------------------" +RESPONSE=$(make_request_with_retry "GET" "$BASE_URL/catalogue/1001" "") +HTTP_CODE=$(echo "$RESPONSE" | tail -n 1) +if [ "$HTTP_CODE" = "200" ]; then + print_result "Get product 1001" true "Product catalogue retrieved" +elif [ "$HTTP_CODE" = "404" ]; then + print_result "Get product 1001" true "Product not found (expected if Drasi hasn't populated data)" +else + print_result "Get product 1001" false "Unexpected response code: $HTTP_CODE" +fi + +# Print summary +echo "" +echo "=================================" +echo "Test Summary" +echo "=================================" +echo "Tests Passed: $TESTS_PASSED" +echo "Tests Failed: $TESTS_FAILED" +echo "" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" +else + echo -e "${RED}Some tests failed!${NC}" +fi + +echo "" +echo "Note: The catalogue service is read-only and depends on data populated by Drasi." +echo "If products return 404, it's expected until:" +echo "1. The Drasi SyncStateStoreReaction is deployed and running" +echo "2. The Drasi query has processed data from products, orders, and reviews" +echo "3. The products have associated orders and reviews" + +exit $TESTS_FAILED \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/App.tsx b/tutorial/dapr/services/catalogue/src/App.tsx new file mode 100644 index 0000000..800670f --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/App.tsx @@ -0,0 +1,24 @@ +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { ProductList } from './components/ProductList'; +import { ProductDetail } from './components/ProductDetail'; + +function App() { + return ( + +
+ + + + } /> + } /> + +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/components/ProductCard.tsx b/tutorial/dapr/services/catalogue/src/components/ProductCard.tsx new file mode 100644 index 0000000..bb32eb6 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/components/ProductCard.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Package, MessageSquare } from 'lucide-react'; +import { CatalogueItem } from '../types'; +import { StarRating } from './StarRating'; + +interface ProductCardProps { + product: CatalogueItem; +} + +export const ProductCard: React.FC = ({ product }) => { + return ( + +
+
+

+ {product.productName} +

+ +
+ +
+
+
+ + + {product.avgRating ? product.avgRating.toFixed(1) : 'N/A'} + +
+
+ + {product.reviewCount} reviews +
+
+
+
+ +
+

+ {product.productDescription || 'No description available'} +

+
+ + ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/components/ProductDetail.tsx b/tutorial/dapr/services/catalogue/src/components/ProductDetail.tsx new file mode 100644 index 0000000..18d5e2b --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/components/ProductDetail.tsx @@ -0,0 +1,119 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { ArrowLeft, Package, MessageSquare, BarChart, Loader2 } from 'lucide-react'; +import { catalogueApi } from '../services/api'; +import { CatalogueItem } from '../types'; +import { StarRating } from './StarRating'; + +export const ProductDetail: React.FC = () => { + const { productId } = useParams<{ productId: string }>(); + const [product, setProduct] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProduct = async () => { + if (!productId) return; + + try { + setLoading(true); + const data = await catalogueApi.getProduct(parseInt(productId)); + setProduct(data); + } catch (err) { + setError('Failed to load product details'); + console.error('Error fetching product:', err); + } finally { + setLoading(false); + } + }; + + fetchProduct(); + }, [productId]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !product) { + return ( +
+ + + Back to Catalogue + +
+

{error || 'Product not found'}

+
+
+ ); + } + + return ( +
+ + + Back to Catalogue + + +
+
+
+
+

{product.productName}

+

{product.productDescription || 'No description available'}

+
+ +
+ +
+
+

+ + Customer Reviews +

+
+
+ + {product.avgRating ? product.avgRating.toFixed(1) : 'N/A'} + out of 5 +
+

Based on {product.reviewCount} reviews

+
+
+ +
+

+ + Product Analytics +

+
+
+
+ Customer Satisfaction + {((product.avgRating || 0) / 5 * 100).toFixed(0)}% +
+
+
+
+
+ +
+

+ Product ID: {product.productId} +

+
+
+
+
+
+
+
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/components/ProductList.tsx b/tutorial/dapr/services/catalogue/src/components/ProductList.tsx new file mode 100644 index 0000000..7e8b13e --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/components/ProductList.tsx @@ -0,0 +1,63 @@ +import React, { useEffect, useState } from 'react'; +import { catalogueApi } from '../services/api'; +import { CatalogueItem } from '../types'; +import { ProductCard } from './ProductCard'; +import { Loader2 } from 'lucide-react'; + +export const ProductList: React.FC = () => { + const [products, setProducts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProducts = async () => { + try { + setLoading(true); + const response = await catalogueApi.getAllProducts(); + setProducts(response.items); + } catch (err) { + setError('Failed to load products'); + console.error('Error fetching products:', err); + } finally { + setLoading(false); + } + }; + + fetchProducts(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (products.length === 0) { + return ( +
+

No products available in the catalogue.

+
+ ); + } + + return ( +
+

Product Catalogue

+
+ {products.map((product) => ( + + ))} +
+
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/components/StarRating.tsx b/tutorial/dapr/services/catalogue/src/components/StarRating.tsx new file mode 100644 index 0000000..83c637d --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/components/StarRating.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Star } from 'lucide-react'; + +interface StarRatingProps { + rating: number; + maxRating?: number; + size?: number; +} + +export const StarRating: React.FC = ({ rating, maxRating = 5, size = 20 }) => { + const fullStars = Math.floor(rating); + const hasHalfStar = rating % 1 >= 0.5; + const emptyStars = maxRating - fullStars - (hasHalfStar ? 1 : 0); + + return ( +
+ {[...Array(fullStars)].map((_, i) => ( + + ))} + {hasHalfStar && ( +
+ +
+ +
+
+ )} + {[...Array(emptyStars)].map((_, i) => ( + + ))} +
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/index.css b/tutorial/dapr/services/catalogue/src/index.css new file mode 100644 index 0000000..f028660 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/index.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + body { + @apply bg-gray-50; + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/main.tsx b/tutorial/dapr/services/catalogue/src/main.tsx new file mode 100644 index 0000000..2fbbbc1 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/services/api.ts b/tutorial/dapr/services/catalogue/src/services/api.ts new file mode 100644 index 0000000..6b5af08 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/services/api.ts @@ -0,0 +1,16 @@ +import axios from 'axios'; +import { CatalogueItem, CatalogueListResponse } from '../types'; + +const API_BASE_URL = '/catalogue-service/api'; + +export const catalogueApi = { + async getAllProducts(): Promise { + const response = await axios.get(`${API_BASE_URL}/catalogue`); + return response.data; + }, + + async getProduct(productId: number): Promise { + const response = await axios.get(`${API_BASE_URL}/catalogue/${productId}`); + return response.data; + } +}; \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/types.ts b/tutorial/dapr/services/catalogue/src/types.ts new file mode 100644 index 0000000..a1649c5 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/types.ts @@ -0,0 +1,12 @@ +export interface CatalogueItem { + productId: number; + productName: string; + productDescription: string; + avgRating: number; + reviewCount: number; +} + +export interface CatalogueListResponse { + items: CatalogueItem[]; + total: number; +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/src/vite-env.d.ts b/tutorial/dapr/services/catalogue/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/tutorial/dapr/services/catalogue/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/tailwind.config.js b/tutorial/dapr/services/catalogue/tailwind.config.js new file mode 100644 index 0000000..89a305e --- /dev/null +++ b/tutorial/dapr/services/catalogue/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/tsconfig.json b/tutorial/dapr/services/catalogue/tsconfig.json new file mode 100644 index 0000000..7a7611e --- /dev/null +++ b/tutorial/dapr/services/catalogue/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/tsconfig.node.json b/tutorial/dapr/services/catalogue/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/tutorial/dapr/services/catalogue/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/tutorial/dapr/services/catalogue/vite.config.ts b/tutorial/dapr/services/catalogue/vite.config.ts new file mode 100644 index 0000000..02d5a30 --- /dev/null +++ b/tutorial/dapr/services/catalogue/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + base: '/catalogue-service/', + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + } + } + } +}) \ No newline at end of file diff --git a/tutorial/dapr/services/common/setup/common-utils.sh b/tutorial/dapr/services/common/setup/common-utils.sh new file mode 100755 index 0000000..2a9af2e --- /dev/null +++ b/tutorial/dapr/services/common/setup/common-utils.sh @@ -0,0 +1,164 @@ +#!/bin/bash + +# Common utilities for service setup and testing scripts + +# Colors for output +export GREEN='\033[0;32m' +export RED='\033[0;31m' +export YELLOW='\033[0;33m' +export BLUE='\033[0;34m' +export NC='\033[0m' # No Color + +# Function to print colored messages +print_info() { + echo -e "${BLUE}$1${NC}" +} + +print_success() { + echo -e "${GREEN}$1${NC}" +} + +print_error() { + echo -e "${RED}$1${NC}" +} + +print_warning() { + echo -e "${YELLOW}$1${NC}" +} + +# Function to make HTTP request with retries +make_request_with_retry() { + local method="$1" + local url="$2" + local data="$3" + local max_retries="${4:-3}" # Default to 3 retries + local retry_delay="${5:-2}" # Default to 2 second delay + + for i in $(seq 1 $max_retries); do + if [ "$method" = "GET" ]; then + response=$(curl -s -w "\n%{http_code}" "$url" 2>&1) + elif [ "$method" = "POST" ]; then + response=$(curl -s -w "\n%{http_code}" -X POST "$url" \ + -H "Content-Type: application/json" \ + -d "$data" 2>&1) + elif [ "$method" = "PUT" ]; then + response=$(curl -s -w "\n%{http_code}" -X PUT "$url" \ + -H "Content-Type: application/json" \ + -d "$data" 2>&1) + elif [ "$method" = "DELETE" ]; then + response=$(curl -s -w "\n%{http_code}" -X DELETE "$url" 2>&1) + fi + + # Check if curl succeeded + if [ $? -eq 0 ]; then + # Extract HTTP code from response + http_code=$(echo "$response" | tail -1) + + # Check if we got a valid HTTP response code + if [[ "$http_code" =~ ^[0-9]+$ ]]; then + # Don't retry on client errors (4xx) or success (2xx, 3xx) + if [ "$http_code" -lt 500 ]; then + echo "$response" + return 0 + fi + + # For 500+ errors, only retry on specific transient errors + if [ "$http_code" -eq 500 ] || [ "$http_code" -eq 502 ] || [ "$http_code" -eq 503 ] || [ "$http_code" -eq 504 ]; then + # Check if it's a transient error by looking at the response body + body=$(echo "$response" | sed '$d') + if echo "$body" | grep -q "Socket closed\|UNAVAILABLE\|Connection refused\|timeout"; then + # This is a transient error, continue with retry + : + else + # Non-transient 500 error, don't retry + echo "$response" + return 0 + fi + else + # Other 5xx error, return without retry + echo "$response" + return 0 + fi + fi + fi + + # Log retry attempt + if [ $i -lt $max_retries ]; then + print_warning " Retry $i/$max_retries: Request failed, retrying in ${retry_delay}s..." >&2 + sleep $retry_delay + fi + done + + # Return the last response even if all retries failed + echo "$response" + return 1 +} + +# Function to wait for service to be ready +wait_for_service() { + local service_url="$1" + local max_wait="${2:-30}" # Default to 30 seconds + local check_interval="${3:-2}" # Default to 2 second intervals + + print_info "Waiting for service at $service_url to be ready..." + + local elapsed=0 + while [ $elapsed -lt $max_wait ]; do + response=$(make_request_with_retry "GET" "$service_url/health" "" 1 0) + http_code=$(echo "$response" | tail -1) + + if [[ "$http_code" =~ ^[0-9]+$ ]] && [ "$http_code" -eq 200 ]; then + print_success "Service is ready!" + return 0 + fi + + sleep $check_interval + elapsed=$((elapsed + check_interval)) + echo -n "." + done + + echo "" + print_error "Service did not become ready within ${max_wait} seconds" + return 1 +} + +# Function to print test result (for test scripts) +print_test_result() { + local test_name="$1" + local success="$2" + local message="$3" + + if [ "$success" = "true" ]; then + echo -e "✓ ${GREEN}PASS${NC}: $test_name" + else + echo -e "✗ ${RED}FAIL${NC}: $test_name" + [ -n "$message" ] && echo " Error: $message" + fi +} + +# Function to check HTTP status code (for test scripts) +check_http_status() { + local expected="$1" + local actual="$2" + local test_name="$3" + local body="$4" + + if [ "$actual" = "$expected" ]; then + print_test_result "$test_name" "true" + return 0 + else + print_test_result "$test_name" "false" "Expected HTTP $expected, got $actual. Response: $body" + return 1 + fi +} + +# Function to generate random ID +generate_random_id() { + echo $((RANDOM * RANDOM % 1000000)) +} + +# Function to generate random email +generate_random_email() { + local prefix="${1:-test}" + echo "${prefix}.$(generate_random_id)@example.com" +} \ No newline at end of file diff --git a/tutorial/dapr/services/customers/Dockerfile b/tutorial/dapr/services/customers/Dockerfile new file mode 100644 index 0000000..c8cd6c7 --- /dev/null +++ b/tutorial/dapr/services/customers/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.13-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY code/ . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/tutorial/dapr/services/customers/code/dapr_client.py b/tutorial/dapr/services/customers/code/dapr_client.py new file mode 100644 index 0000000..38b3f81 --- /dev/null +++ b/tutorial/dapr/services/customers/code/dapr_client.py @@ -0,0 +1,119 @@ +import json +import logging +import os +import base64 +from typing import Optional, Any, List, Dict +from dapr.clients import DaprClient + +logger = logging.getLogger(__name__) + + +class DaprStateStore: + def __init__(self, store_name: Optional[str] = None): + self.store_name = store_name or os.getenv("DAPR_STORE_NAME", "customers-store") + self.client = DaprClient() + logger.info(f"Initialized Dapr state store client for store: {self.store_name}") + + async def get_item(self, key: str) -> Optional[dict]: + """Get an item from the state store.""" + try: + response = self.client.get_state( + store_name=self.store_name, + key=key + ) + + if response.data: + data = json.loads(response.data) + logger.debug(f"Retrieved item with key '{key}': {data}") + return data + else: + logger.debug(f"No item found with key '{key}'") + return None + + except Exception as e: + logger.error(f"Error getting item with key '{key}': {str(e)}") + raise + + async def save_item(self, key: str, data: dict) -> None: + """Save an item to the state store.""" + try: + self.client.save_state( + store_name=self.store_name, + key=key, + value=json.dumps(data) + ) + logger.debug(f"Saved item with key '{key}': {data}") + + except Exception as e: + logger.error(f"Error saving item with key '{key}': {str(e)}") + raise + + async def delete_item(self, key: str) -> None: + """Delete an item from the state store.""" + try: + self.client.delete_state( + store_name=self.store_name, + key=key + ) + logger.debug(f"Deleted item with key '{key}'") + + except Exception as e: + logger.error(f"Error deleting item with key '{key}': {str(e)}") + raise + + async def query_items(self, query: Dict[str, Any]) -> tuple[List[Dict[str, Any]], Optional[str]]: + """ + Query items from the state store using Dapr state query API. + + Args: + query: Query dictionary with filter, sort, and page options + + Returns: + Tuple of (results list, pagination token) + """ + try: + query_json = json.dumps(query) + logger.debug(f"Executing state query with: {query_json}") + response = self.client.query_state( + store_name=self.store_name, + query=query_json + ) + + results = [] + for item in response.results: + try: + # The value might already be a string (JSON), not bytes + if hasattr(item.value, 'decode'): + # It's bytes, decode it + value_str = item.value.decode('UTF-8') + else: + # It's already a string + value_str = item.value + + # Parse the JSON string + value = json.loads(value_str) + + # If the value is a string, it might be base64 encoded JSON + if isinstance(value, str): + try: + # Try base64 decoding + decoded_bytes = base64.b64decode(value) + decoded_str = decoded_bytes.decode('utf-8') + value = json.loads(decoded_str) + except Exception: + # Keep the original string value if base64 decode fails + pass + + results.append({ + 'key': item.key, + 'value': value + }) + except Exception as e: + logger.error(f"Failed to parse item with key {item.key}: {e}") + + logger.debug(f"Query completed - returned {len(results)} items") + return results, response.token + + except Exception as e: + logger.error(f"Error querying state store: {str(e)}") + raise \ No newline at end of file diff --git a/tutorial/dapr/services/customers/code/main.py b/tutorial/dapr/services/customers/code/main.py new file mode 100644 index 0000000..ca8d3e0 --- /dev/null +++ b/tutorial/dapr/services/customers/code/main.py @@ -0,0 +1,283 @@ +import logging +import os +import time +from contextlib import asynccontextmanager +from typing import Optional +import random + +from fastapi import FastAPI, HTTPException, Depends, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from models import CustomerItem, CustomerCreateRequest, CustomerUpdateRequest, CustomerResponse, CustomerListResponse, LoyaltyTier +from dapr_client import DaprStateStore + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global state store instance +state_store = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global state_store + state_store = DaprStateStore() + logger.info("Customer service started") + yield + # Shutdown + logger.info("Customer service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Customer Service", + description="Manages customer information with Dapr state store", + version="1.0.0", + lifespan=lifespan, + root_path="/customers-service" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_state_store() -> DaprStateStore: + """Dependency to get the state store instance.""" + if state_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="State store not initialized" + ) + return state_store + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "customers"} + + +@app.get("/customers", response_model=CustomerListResponse) +async def list_customers( + store: DaprStateStore = Depends(get_state_store) +): + """Get all customers.""" + start_time = time.time() + + try: + # Simple query with empty filter to get all items + query = { + "filter": {} + } + + # Execute the query + results, _ = await store.query_items(query) + + # Convert results to CustomerResponse objects + items = [] + for result in results: + try: + customer_item = CustomerItem.from_db_dict(result['value']) + items.append(CustomerResponse.from_customer_item(customer_item)) + except Exception as e: + logger.warning(f"Failed to parse item with key {result['key']}: {str(e)}") + continue + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved {len(items)} customers in {elapsed:.2f}ms") + + return CustomerListResponse(items=items, total=len(items)) + + except Exception as e: + elapsed = (time.time() - start_time) * 1000 + logger.error(f"Failed to list customers after {elapsed:.2f}ms: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list customers: {str(e)}" + ) + + +@app.post("/customers", response_model=CustomerResponse, status_code=status.HTTP_201_CREATED) +async def create_customer( + request: CustomerCreateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Create a new customer.""" + start_time = time.time() + + try: + # Use provided customer ID or generate a unique one + if request.customerId: + customer_id = request.customerId + # Check if ID already exists + existing = await store.get_item(str(customer_id)) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Customer with ID {customer_id} already exists" + ) + else: + # Generate a unique customer ID + customer_id = random.randint(1000, 999999) + # Check if ID already exists + existing = await store.get_item(str(customer_id)) + while existing: + customer_id = random.randint(1000, 999999) + existing = await store.get_item(str(customer_id)) + + # Create customer item + customer_item = CustomerItem( + customerId=customer_id, + customerName=request.customerName, + loyaltyTier=request.loyaltyTier, + email=request.email + ) + + # Save to state store + await store.save_item(str(customer_id), customer_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Created customer {customer_id} in {elapsed:.2f}ms") + + return CustomerResponse.from_customer_item(customer_item) + + except Exception as e: + logger.error(f"Error creating customer: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create customer: {str(e)}" + ) + + +@app.get("/customers/{customer_id}", response_model=CustomerResponse) +async def get_customer( + customer_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Get customer details.""" + start_time = time.time() + + try: + data = await store.get_item(str(customer_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer {customer_id} not found" + ) + + customer_item = CustomerItem.from_db_dict(data) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved customer {customer_id} in {elapsed:.2f}ms") + + return CustomerResponse.from_customer_item(customer_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving customer: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve customer: {str(e)}" + ) + + +@app.put("/customers/{customer_id}", response_model=CustomerResponse) +async def update_customer( + customer_id: int, + request: CustomerUpdateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Update customer details.""" + start_time = time.time() + + try: + # Get current customer + data = await store.get_item(str(customer_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer {customer_id} not found" + ) + + customer_item = CustomerItem.from_db_dict(data) + + # Update fields if provided + if request.customerName is not None: + customer_item.customerName = request.customerName + if request.loyaltyTier is not None: + customer_item.loyaltyTier = request.loyaltyTier + if request.email is not None: + customer_item.email = request.email + + # Save back to state store + await store.save_item(str(customer_id), customer_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Updated customer {customer_id} in {elapsed:.2f}ms") + + return CustomerResponse.from_customer_item(customer_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating customer: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update customer: {str(e)}" + ) + + +@app.delete("/customers/{customer_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_customer( + customer_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Delete a customer.""" + start_time = time.time() + + try: + # Check if customer exists + data = await store.get_item(str(customer_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Customer {customer_id} not found" + ) + + # Delete from state store + await store.delete_item(str(customer_id)) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Deleted customer {customer_id} in {elapsed:.2f}ms") + + return None + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting customer: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete customer: {str(e)}" + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/customers/code/models.py b/tutorial/dapr/services/customers/code/models.py new file mode 100644 index 0000000..9df31fd --- /dev/null +++ b/tutorial/dapr/services/customers/code/models.py @@ -0,0 +1,81 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, List +from enum import Enum + + +class LoyaltyTier(str, Enum): + BRONZE = "BRONZE" + SILVER = "SILVER" + GOLD = "GOLD" + + +class CustomerItem(BaseModel): + customerId: int = Field(..., description="Unique customer identifier") + customerName: str = Field(..., description="Name of the customer") + loyaltyTier: LoyaltyTier = Field(..., description="Customer loyalty tier") + email: str = Field(..., description="Customer email address") + + def to_db_dict(self) -> dict: + """Convert to database format with snake_case.""" + return { + "customer_id": self.customerId, + "customer_name": self.customerName, + "loyalty_tier": self.loyaltyTier.value, + "email": self.email + } + + @classmethod + def from_db_dict(cls, data: dict) -> "CustomerItem": + """Create from database format with snake_case.""" + return cls( + customerId=data["customer_id"], + customerName=data["customer_name"], + loyaltyTier=LoyaltyTier(data["loyalty_tier"]), + email=data["email"] + ) + + +class CustomerCreateRequest(BaseModel): + customerId: Optional[int] = Field(None, description="Unique customer identifier (auto-generated if not provided)") + customerName: str = Field(..., description="Name of the customer") + email: str = Field(..., description="Customer email address") + loyaltyTier: LoyaltyTier = Field(default=LoyaltyTier.BRONZE, description="Customer loyalty tier") + + @validator('email') + def validate_email(cls, v): + if '@' not in v: + raise ValueError('Invalid email address') + return v + + +class CustomerUpdateRequest(BaseModel): + customerName: Optional[str] = Field(None, description="Name of the customer") + loyaltyTier: Optional[LoyaltyTier] = Field(None, description="Customer loyalty tier") + email: Optional[str] = Field(None, description="Customer email address") + + @validator('email') + def validate_email(cls, v): + if v and '@' not in v: + raise ValueError('Invalid email address') + return v + + +class CustomerResponse(BaseModel): + customerId: int + customerName: str + loyaltyTier: LoyaltyTier + email: str + + @staticmethod + def from_customer_item(item: CustomerItem) -> "CustomerResponse": + return CustomerResponse( + customerId=item.customerId, + customerName=item.customerName, + loyaltyTier=item.loyaltyTier, + email=item.email + ) + + +class CustomerListResponse(BaseModel): + items: List[CustomerResponse] + total: int \ No newline at end of file diff --git a/tutorial/dapr/services/customers/k8s/dapr/statestore.yaml b/tutorial/dapr/services/customers/k8s/dapr/statestore.yaml new file mode 100644 index 0000000..6d17ef7 --- /dev/null +++ b/tutorial/dapr/services/customers/k8s/dapr/statestore.yaml @@ -0,0 +1,16 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: customers-store +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=customers-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=customersdb sslmode=disable" + - name: tableName + value: "customers" + - name: keyPrefix + value: "none" + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/customers/k8s/deployment.yaml b/tutorial/dapr/services/customers/k8s/deployment.yaml new file mode 100644 index 0000000..0a5cb34 --- /dev/null +++ b/tutorial/dapr/services/customers/k8s/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: customers + labels: + app: customers +spec: + replicas: 1 + selector: + matchLabels: + app: customers + template: + metadata: + labels: + app: customers + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "customers" + dapr.io/app-port: "8000" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: customers + image: ghcr.io/drasi-project/learning/dapr/customers-service:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: DAPR_STORE_NAME + value: "customers-store" + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: customers + labels: + app: customers +spec: + selector: + app: customers + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: customers-stripprefix +spec: + stripPrefix: + prefixes: + - /customers-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: customers + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-customers-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /customers-service + pathType: Prefix + backend: + service: + name: customers + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/customers/k8s/postgres/postgres.yaml b/tutorial/dapr/services/customers/k8s/postgres/postgres.yaml new file mode 100644 index 0000000..482bf17 --- /dev/null +++ b/tutorial/dapr/services/customers/k8s/postgres/postgres.yaml @@ -0,0 +1,107 @@ +apiVersion: v1 +kind: Secret +metadata: + name: customers-db-credentials + labels: + app: customers-db +type: Opaque +stringData: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: customers-db + labels: + app: customers-db +spec: + serviceName: customers-db + replicas: 1 + selector: + matchLabels: + app: customers-db + template: + metadata: + labels: + app: customers-db + spec: + containers: + - name: postgres + image: postgres:14 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: customers-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: customers-db-credentials + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: customersdb + args: + - -c + - wal_level=logical + - -c + - max_replication_slots=5 + - -c + - max_wal_senders=10 + volumeMounts: + - name: customers-db-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + limits: + cpu: "1" + memory: "1Gi" + requests: + cpu: "0.5" + memory: "512Mi" + volumeClaimTemplates: + - metadata: + name: customers-db-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: customers-db + labels: + app: customers-db +spec: + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres + selector: + app: customers-db + type: ClusterIP \ No newline at end of file diff --git a/tutorial/dapr/services/customers/requirements.txt b/tutorial/dapr/services/customers/requirements.txt new file mode 100644 index 0000000..9cccb30 --- /dev/null +++ b/tutorial/dapr/services/customers/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.24.0 +pydantic==2.10.5 +dapr==1.15.0 +python-json-logger==2.0.7 \ No newline at end of file diff --git a/tutorial/dapr/services/customers/setup/load-initial-data.sh b/tutorial/dapr/services/customers/setup/load-initial-data.sh new file mode 100755 index 0000000..f43ee3a --- /dev/null +++ b/tutorial/dapr/services/customers/setup/load-initial-data.sh @@ -0,0 +1,73 @@ +#!/bin/bash + +# Script to load initial customer data via the Customer Service API +# Usage: ./load-initial-data.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/customers-service}" + +echo "Loading initial customer data to: $BASE_URL" +echo "========================================" + +# Wait for service to be ready +if ! wait_for_service "$BASE_URL"; then + print_error "Service is not ready. Exiting." + exit 1 +fi + +echo "" +# Initial customer data with explicit IDs +declare -a customers=( + '{"customerId": 1, "customerName": "Alice Johnson", "loyaltyTier": "GOLD", "email": "alice.johnson@email.com"}' + '{"customerId": 2, "customerName": "Bob Smith", "loyaltyTier": "SILVER", "email": "bob.smith@email.com"}' + '{"customerId": 3, "customerName": "Charlie Brown", "loyaltyTier": "BRONZE", "email": "charlie.brown@email.com"}' + '{"customerId": 4, "customerName": "Diana Prince", "loyaltyTier": "GOLD", "email": "diana.prince@email.com"}' + '{"customerId": 5, "customerName": "Edward Norton", "loyaltyTier": "SILVER", "email": "edward.norton@email.com"}' + '{"customerId": 6, "customerName": "Fiona Green", "loyaltyTier": "BRONZE", "email": "fiona.green@email.com"}' + '{"customerId": 7, "customerName": "George Wilson", "loyaltyTier": "GOLD", "email": "george.wilson@email.com"}' + '{"customerId": 8, "customerName": "Helen Parker", "loyaltyTier": "SILVER", "email": "helen.parker@email.com"}' + '{"customerId": 9, "customerName": "Ian McKay", "loyaltyTier": "BRONZE", "email": "ian.mckay@email.com"}' + '{"customerId": 10, "customerName": "Julia Roberts", "loyaltyTier": "GOLD", "email": "julia.roberts@email.com"}' +) + +# Track results +success_count=0 +fail_count=0 + +# Create each customer +for customer in "${customers[@]}"; do + id=$(echo "$customer" | grep -o '"customerId": [0-9]*' | cut -d' ' -f2) + name=$(echo "$customer" | grep -o '"customerName": "[^"]*"' | cut -d'"' -f4) + echo -n "Creating customer ID $id: $name... " + + response=$(make_request_with_retry "POST" "$BASE_URL/customers" "$customer") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ]; then + print_success "SUCCESS" + ((success_count++)) + else + print_error "FAILED (HTTP $http_code)" + echo " Error: $body" + ((fail_count++)) + fi +done + +echo "========================================" +echo "Summary: $success_count succeeded, $fail_count failed" + +if [ $fail_count -eq 0 ]; then + print_success "All customers loaded successfully!" + echo "" + echo "Customer IDs: 1-10" + exit 0 +else + print_error "Some customers failed to load" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/customers/setup/test-apis.sh b/tutorial/dapr/services/customers/setup/test-apis.sh new file mode 100755 index 0000000..1e1603e --- /dev/null +++ b/tutorial/dapr/services/customers/setup/test-apis.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +# Script to perform sanity check on Customer Service APIs +# Usage: ./test-apis.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/customers-service}" + +echo "Customer Service API Sanity Check" +echo "=================================" +echo "Base URL: $BASE_URL" +echo "" + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to print test result with counter +print_result() { + local test_name="$1" + local success="$2" + local message="$3" + + print_test_result "$test_name" "$success" "$message" + + if [ "$success" = "true" ]; then + ((TESTS_PASSED++)) + else + ((TESTS_FAILED++)) + fi +} + +# Function to check HTTP status code with counter +check_status() { + local expected="$1" + local actual="$2" + local test_name="$3" + local body="$4" + + if check_http_status "$expected" "$actual" "$test_name" "$body"; then + ((TESTS_PASSED++)) + return 0 + else + ((TESTS_FAILED++)) + return 1 + fi +} + +echo "1. Testing Health Check" +echo "-----------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/health" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "200" "$http_code" "Health check endpoint" "$body" + +echo "" +echo "2. Creating Test Customer" +echo "------------------------" +TEST_CUSTOMER='{ + "customerName": "Test Customer", + "loyaltyTier": "SILVER", + "email": "test.customer@example.com" +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/customers" "$TEST_CUSTOMER") + +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "201" "$http_code" "Create customer" "$body"; then + CUSTOMER_ID=$(echo "$body" | grep -o '"customerId":[0-9]*' | cut -d':' -f2) + echo " Created customer with ID: $CUSTOMER_ID" +else + echo "Failed to create customer. Stopping tests." + exit 1 +fi + +echo "" +echo "3. Getting Created Customer" +echo "--------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/customers/$CUSTOMER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Get customer" "$body"; then + # Verify the customer data + if echo "$body" | grep -q "Test Customer" && echo "$body" | grep -q "SILVER"; then + print_result "Verify customer data" "true" "" + else + print_result "Verify customer data" "false" "Customer data doesn't match expected values" + fi +fi + +echo "" +echo "4. Updating Customer" +echo "-------------------" +UPDATE_DATA='{ + "customerName": "Updated Test Customer", + "loyaltyTier": "GOLD", + "email": "updated.test@example.com" +}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/customers/$CUSTOMER_ID" "$UPDATE_DATA") + +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Update customer" "$body"; then + # Verify the update + if echo "$body" | grep -q "Updated Test Customer" && echo "$body" | grep -q "GOLD"; then + print_result "Verify updated data" "true" "" + else + print_result "Verify updated data" "false" "Updated data doesn't match expected values" + fi +fi + +echo "" +echo "5. Testing 404 for Non-existent Customer" +echo "---------------------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/customers/999999999" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get non-existent customer" "$body" + +echo "" +echo "6. Deleting Test Customer" +echo "------------------------" +response=$(make_request_with_retry "DELETE" "$BASE_URL/customers/$CUSTOMER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "204" "$http_code" "Delete customer" "$body" + +echo "" +echo "7. Verifying Deletion" +echo "--------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/customers/$CUSTOMER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get deleted customer" "$body" + +echo "" +echo "=================================" +echo "Test Summary" +echo "=================================" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "\n${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}Some tests failed!${NC}" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/.gitignore b/tutorial/dapr/services/dashboard/.gitignore new file mode 100644 index 0000000..c67e112 --- /dev/null +++ b/tutorial/dapr/services/dashboard/.gitignore @@ -0,0 +1,38 @@ +# Dependencies +node_modules +.pnp +.pnp.js + +# Production +dist +build + +# Environment +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Editor +.vscode +.idea +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage + +# TypeScript +*.tsbuildinfo \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/Dockerfile b/tutorial/dapr/services/dashboard/Dockerfile new file mode 100644 index 0000000..ff1fac5 --- /dev/null +++ b/tutorial/dapr/services/dashboard/Dockerfile @@ -0,0 +1,35 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Runtime stage +FROM nginx:alpine + +# Copy built assets from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Create a script to inject environment variables at runtime +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +# Expose port +EXPOSE 80 + +# Start nginx with environment variable injection +ENTRYPOINT ["/docker-entrypoint.sh"] \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/docker-entrypoint.sh b/tutorial/dapr/services/dashboard/docker-entrypoint.sh new file mode 100644 index 0000000..1c12291 --- /dev/null +++ b/tutorial/dapr/services/dashboard/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# Replace environment variables in the built app +# This allows runtime configuration of the React app + +# Create a config file with environment variables +cat > /usr/share/nginx/html/env-config.js <|\n|' /usr/share/nginx/html/index.html + +# Start nginx +nginx -g 'daemon off;' \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/index.html b/tutorial/dapr/services/dashboard/index.html new file mode 100644 index 0000000..b329d4d --- /dev/null +++ b/tutorial/dapr/services/dashboard/index.html @@ -0,0 +1,12 @@ + + + + + + Drasi Live Monitoring + + +
+ + + \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/k8s/deployment.yaml b/tutorial/dapr/services/dashboard/k8s/deployment.yaml new file mode 100644 index 0000000..173fcaa --- /dev/null +++ b/tutorial/dapr/services/dashboard/k8s/deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dashboard +spec: + replicas: 1 + selector: + matchLabels: + app: dashboard + template: + metadata: + labels: + app: dashboard + spec: + containers: + - name: dashboard + image: ghcr.io/drasi-project/learning/dapr/dashboard-service:latest + imagePullPolicy: Always + ports: + - containerPort: 80 + env: + - name: VITE_STOCK_QUERY_ID + value: "at-risk-orders-query" + - name: VITE_GOLD_QUERY_ID + value: "delayed-gold-orders-query" + resources: + limits: + memory: "256Mi" + cpu: "250m" + requests: + memory: "128Mi" + cpu: "100m" +--- +apiVersion: v1 +kind: Service +metadata: + name: dashboard +spec: + selector: + app: dashboard + ports: + - port: 80 + targetPort: 80 + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: dashboard-stripprefix +spec: + stripPrefix: + prefixes: + - /dashboard +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: dashboard + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-dashboard-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /dashboard + pathType: Prefix + backend: + service: + name: dashboard + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/k8s/signalr-ingress.yaml b/tutorial/dapr/services/dashboard/k8s/signalr-ingress.yaml new file mode 100644 index 0000000..bc53eb9 --- /dev/null +++ b/tutorial/dapr/services/dashboard/k8s/signalr-ingress.yaml @@ -0,0 +1,29 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: signalr-gateway + namespace: drasi-system + annotations: + traefik.ingress.kubernetes.io/router.middlewares: drasi-system-signalr-stripprefix@kubernetescrd +spec: + ingressClassName: traefik + rules: + - http: + paths: + - path: /signalr + pathType: Prefix + backend: + service: + name: signalr-gateway + port: + number: 8080 +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: signalr-stripprefix + namespace: drasi-system +spec: + stripPrefix: + prefixes: + - /signalr \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/nginx.conf b/tutorial/dapr/services/dashboard/nginx.conf new file mode 100644 index 0000000..de72a7c --- /dev/null +++ b/tutorial/dapr/services/dashboard/nginx.conf @@ -0,0 +1,38 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + # Handle the root path + location / { + try_files $uri $uri/ /index.html; + } + + # Also handle dashboard base path (in case accessed directly) + location /dashboard { + try_files $uri $uri/ /index.html; + } + + # Serve env-config.js with correct MIME type + location ~ ^/dashboard/env-config\.js$ { + alias /usr/share/nginx/html/env-config.js; + add_header Content-Type application/javascript; + } + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Don't cache HTML files + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0"; + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/package-lock.json b/tutorial/dapr/services/dashboard/package-lock.json new file mode 100644 index 0000000..743b044 --- /dev/null +++ b/tutorial/dapr/services/dashboard/package-lock.json @@ -0,0 +1,3535 @@ +{ + "name": "drasi-monitoring-dashboard", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "drasi-monitoring-dashboard", + "version": "1.0.0", + "dependencies": { + "@drasi/signalr-react": "^0.1.0-alpha", + "axios": "^1.6.2", + "lucide-react": "^0.263.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "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/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@drasi/signalr-react": { + "version": "0.1.0-alpha", + "resolved": "https://registry.npmjs.org/@drasi/signalr-react/-/signalr-react-0.1.0-alpha.tgz", + "integrity": "sha512-vr/O/5FC+nBMpvaEszWi8uxGgITc9wsZWJHekBQPTtZuMesMOkwDItKEaYZyupuVnA319ljoRCzwTGbpMK3a4A==", + "license": "Apache-2.0", + "dependencies": { + "@microsoft/signalr": "^8.0.7", + "murmurhash": "^2.0.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "8.0.17", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-8.0.17.tgz", + "integrity": "sha512-5pM6xPtKZNJLO0Tq5nQasVyPFwi/WBY3QB5uc/v3dIPTpS1JXQbaXAQAPxFoQ5rTBFE094w8bbqkp17F9ReQvA==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz", + "integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz", + "integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz", + "integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz", + "integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz", + "integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz", + "integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz", + "integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz", + "integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz", + "integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz", + "integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz", + "integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz", + "integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz", + "integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz", + "integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz", + "integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz", + "integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz", + "integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz", + "integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz", + "integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz", + "integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==", + "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.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@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 || ^7.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "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.30001733", + "electron-to-chromium": "^1.5.199", + "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/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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 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/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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "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==", + "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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.204", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.204.tgz", + "integrity": "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "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/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/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.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/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "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/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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==", + "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/lucide-react": { + "version": "0.263.1", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.263.1.tgz", + "integrity": "sha512-keqxAx97PlaEN89PXZ6ki1N8nRjGWtDa4021GFYLNj0RgruM5odbpl8GHTExj0hhPq3sF6Up0gnxt6TSHu+ovw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/murmurhash": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/murmurhash/-/murmurhash-2.0.1.tgz", + "integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "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/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "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==", + "dev": true, + "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/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.46.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz", + "integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.3", + "@rollup/rollup-android-arm64": "4.46.3", + "@rollup/rollup-darwin-arm64": "4.46.3", + "@rollup/rollup-darwin-x64": "4.46.3", + "@rollup/rollup-freebsd-arm64": "4.46.3", + "@rollup/rollup-freebsd-x64": "4.46.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", + "@rollup/rollup-linux-arm-musleabihf": "4.46.3", + "@rollup/rollup-linux-arm64-gnu": "4.46.3", + "@rollup/rollup-linux-arm64-musl": "4.46.3", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", + "@rollup/rollup-linux-ppc64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-musl": "4.46.3", + "@rollup/rollup-linux-s390x-gnu": "4.46.3", + "@rollup/rollup-linux-x64-gnu": "4.46.3", + "@rollup/rollup-linux-x64-musl": "4.46.3", + "@rollup/rollup-win32-arm64-msvc": "4.46.3", + "@rollup/rollup-win32-ia32-msvc": "4.46.3", + "@rollup/rollup-win32-x64-msvc": "4.46.3", + "fsevents": "~2.3.2" + } + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.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/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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "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/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "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.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/tutorial/dapr/services/dashboard/package.json b/tutorial/dapr/services/dashboard/package.json new file mode 100644 index 0000000..3de8ff1 --- /dev/null +++ b/tutorial/dapr/services/dashboard/package.json @@ -0,0 +1,28 @@ +{ + "name": "drasi-monitoring-dashboard", + "version": "1.0.0", + "type": "module", + "private": true, + "dependencies": { + "@drasi/signalr-react": "^0.1.0-alpha", + "axios": "^1.6.2", + "lucide-react": "^0.263.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.3.3", + "vite": "^5.0.10" + }, + "scripts": { + "start": "vite --port ${VITE_PORT:-3000}", + "build": "tsc && vite build", + "preview": "vite preview" + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/postcss.config.js b/tutorial/dapr/services/dashboard/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/tutorial/dapr/services/dashboard/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/App.tsx b/tutorial/dapr/services/dashboard/src/App.tsx new file mode 100644 index 0000000..b0b273c --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/App.tsx @@ -0,0 +1,7 @@ +import Dashboard from './components/Dashboard' + +function App() { + return +} + +export default App \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/components/ConnectionStatus.tsx b/tutorial/dapr/services/dashboard/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..338929a --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/components/ConnectionStatus.tsx @@ -0,0 +1,41 @@ +import { useEffect, useState } from 'react' +import { ReactionListener } from '@drasi/signalr-react' + +interface ConnectionStatusProps { + url: string +} + +export default function ConnectionStatus({ url }: ConnectionStatusProps) { + const [isConnected, setIsConnected] = useState(false) + + useEffect(() => { + // Create a listener with a dummy query ID just to monitor connection + const listener = new ReactionListener( + url, + 'connection-monitor', + () => {} // Empty callback since we only care about connection status + ) + + // Access the internal SignalR connection + const hubConnection = (listener as any)['sigRConn'].connection + + // Set up connection state handlers + hubConnection.onclose(() => setIsConnected(false)) + hubConnection.onreconnecting(() => setIsConnected(false)) + hubConnection.onreconnected(() => setIsConnected(true)) + + // Check initial connection status + ;(listener as any)['sigRConn'].started + .then(() => setIsConnected(true)) + .catch(() => setIsConnected(false)) + + // No cleanup needed as ReactionListener handles connection cleanup + }, [url]) + + return ( +
+
+ {isConnected ? 'Real-time updates active' : 'Disconnected'} +
+ ) +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/components/Dashboard.tsx b/tutorial/dapr/services/dashboard/src/components/Dashboard.tsx new file mode 100644 index 0000000..2906ebf --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/components/Dashboard.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { Package, Crown, TrendingUp } from 'lucide-react'; +import { config } from '../config'; +import StockRiskView from './StockRiskView'; +import GoldCustomerDelaysView from './GoldCustomerDelaysView'; +import ConnectionStatus from './ConnectionStatus'; + +export default function Dashboard() { + const [activeTab, setActiveTab] = useState<'stock' | 'gold'>('stock'); + // stockCount and goldCount state variables removed + + const { signalrUrl, stockQueryId, goldQueryId } = config; + + if (!signalrUrl) { + return ( +
+
+

Configuration Error

+

SignalR URL is not configured. Please check your environment variables.

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+
+
+ +

Drasi Live Monitoring

+
+
+ +
+
+
+
+ + {/* Tab Navigation */} +
+
+ +
+
+ + {/* Content */} +
+ {activeTab === 'stock' && ( + + )} + {activeTab === 'gold' && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/components/GoldCustomerDelaysView.tsx b/tutorial/dapr/services/dashboard/src/components/GoldCustomerDelaysView.tsx new file mode 100644 index 0000000..28ec457 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/components/GoldCustomerDelaysView.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState, useRef } from 'react'; +import { ResultSet } from '@drasi/signalr-react'; +import { Crown, Clock } from 'lucide-react'; +import type { GoldCustomerDelay } from '../types'; + +interface GoldCustomerDelaysViewProps { + signalrUrl: string; + queryId: string; +} + +// Format duration function from spec +const formatDuration = (startTimeISO: string, currentTime: number): string => { + if (!startTimeISO) return 'N/A'; + const startTime = new Date(startTimeISO).getTime(); + if (isNaN(startTime) || startTime > currentTime) return 'N/A'; + + const totalSeconds = Math.floor((currentTime - startTime) / 1000); + if (totalSeconds < 0) return '0s'; // Should not happen if startTime <= currentTime + + const minutes = Math.floor(totalSeconds / 60); + const remainingSeconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}m ${remainingSeconds}s`; + } + return `${totalSeconds}s`; +}; + +interface GoldCustomerCardProps { + issue: GoldCustomerDelay; +} + +const GoldCustomerCard = ({ issue }: GoldCustomerCardProps) => { + const [currentTime, setCurrentTime] = useState(window.Date.now()); + + useEffect(() => { + const timerId = setInterval(() => { + setCurrentTime(window.Date.now()); + }, 1000); + return () => clearInterval(timerId); + }, []); + + const duration = formatDuration(issue.waitingSince, currentTime); + + return ( +
+
+
+
+ + Gold Customer +
+ +
+
+ +
+
+

{issue.customerName}

+

{issue.customerEmail}

+
+ +
+
+ Order ID + {issue.orderId} +
+
+ Customer ID + {issue.customerId} +
+
+ Status + + {issue.orderStatus} + +
+
+ Stuck Duration + + {duration} + +
+
+ +
+ +
+
+
+ ); +}; + +export default function GoldCustomerDelaysView({ signalrUrl, queryId }: GoldCustomerDelaysViewProps) { + const [goldIssues, setGoldIssues] = useState([]); + const currentRawItemsRef = useRef([]); + + const processAndSetIssues = () => { + const rawItems = [...currentRawItemsRef.current]; + currentRawItemsRef.current = []; + setGoldIssues(rawItems); + }; + + if (!queryId) { + return
Error: Gold Query ID is not configured.
; + } + + return ( +
+
+

Gold Customer Order Delays

+

+ Gold tier customers with orders stuck in PROCESSING state. +

+
+ +
+ {goldIssues.map((issue) => ( + + ))} +
+ {goldIssues.length === 0 && ( +
+ No gold customer order delays at the moment. +
+ )} + + + {(item: GoldCustomerDelay) => { + currentRawItemsRef.current.push(item); + return null; + }} + + +
+ ); +} + +const RenderWatcher = ({ onRender }: { onRender: () => void }) => { + useEffect(() => { + const timeoutId = setTimeout(onRender, 0); + return () => clearTimeout(timeoutId); + }, [onRender]); + return null; +}; \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/components/StockRiskView.tsx b/tutorial/dapr/services/dashboard/src/components/StockRiskView.tsx new file mode 100644 index 0000000..0213087 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/components/StockRiskView.tsx @@ -0,0 +1,231 @@ +import { useEffect, useState, useRef } from 'react'; +import { ResultSet } from '@drasi/signalr-react'; +import { ShoppingCart, AlertTriangle, RefreshCw, XCircle, Loader2 } from 'lucide-react'; // Added Loader2 +import type { StockIssueItem } from '../types'; +import { productsApi, ordersApi } from '../services/api'; // Import APIs + +interface GroupedStockOrder { + orderId: string; + customerId: string; + orderStatus: string; + items: StockIssueItem[]; +} + +interface StockRiskViewProps { + signalrUrl: string; + queryId: string; +} + +const getStockSeverity = (quantity: number, stockOnHand: number) => { + if (stockOnHand < 0) stockOnHand = 0; + const shortage = quantity - stockOnHand; + if (stockOnHand === 0 && quantity > 0) return 'critical'; + if (shortage > 0 && shortage >= quantity * 0.5) return 'high'; + if (shortage > 0) return 'medium'; + return 'low'; +}; + +const getSeverityColor = (severity: string) => { + switch (severity) { + case 'critical': return 'bg-red-100 border-red-300 text-red-700'; + case 'high': return 'bg-orange-100 border-orange-300 text-orange-700'; + case 'medium': return 'bg-yellow-100 border-yellow-300 text-yellow-700'; + default: return 'bg-gray-100 border-gray-300 text-gray-700'; + } +}; + + +export default function StockRiskView({ signalrUrl, queryId }: StockRiskViewProps) { + const [processedOrders, setProcessedOrders] = useState([]); + const currentRawItemsRef = useRef([]); + const [loadingStates, setLoadingStates] = useState>({}); // For button loading + + const processAndSetOrders = () => { + // ... (keep existing processAndSetOrders function) + const rawItems = [...currentRawItemsRef.current]; + currentRawItemsRef.current = []; + + const grouped = new Map(); + + rawItems.forEach(item => { + if (!grouped.has(item.orderId)) { + grouped.set(item.orderId, { + orderId: item.orderId, + customerId: item.customerId, + orderStatus: item.orderStatus, + items: [], + }); + } + if (item.quantity > item.stockOnHand) { + grouped.get(item.orderId)!.items.push(item); + } + }); + + const newProcessedOrders = Array.from(grouped.values()).filter(order => order.items.length > 0); + setProcessedOrders(newProcessedOrders); + }; + + const handleBackorder = async (productId: string, quantityOrdered: number, currentStock: number) => { + const loadingKey = `backorder-${productId}`; + setLoadingStates(prev => ({ ...prev, [loadingKey]: true })); + + const shortage = quantityOrdered - (currentStock < 0 ? 0 : currentStock); + if (shortage <= 0) { + console.log(`No shortage for product ${productId}, no backorder needed.`); + setLoadingStates(prev => ({ ...prev, [loadingKey]: false })); + return; + } + + const result = await productsApi.incrementStock(productId, shortage); + if (result.success) { + // Optionally: show success message + // Data should refresh via SignalR eventually + console.log(`Product ${productId} stock incremented by ${shortage} for backorder.`); + } else { + // Optionally: show error message + console.error(`Failed to backorder product ${productId}:`, result.error); + alert(`Failed to backorder product ${productId}: ${result.error?.detail || result.error}`); + } + setLoadingStates(prev => ({ ...prev, [loadingKey]: false })); + }; + + const handleCancelOrder = async (orderId: string) => { + const loadingKey = `cancel-${orderId}`; + setLoadingStates(prev => ({ ...prev, [loadingKey]: true })); + + const result = await ordersApi.cancelOrder(orderId); + if (result.success) { + // Data should refresh via SignalR eventually + console.log(`Order ${orderId} cancelled.`); + } else { + console.error(`Failed to cancel order ${orderId}:`, result.error); + alert(`Failed to cancel order ${orderId}: ${result.error?.detail || result.error}`); + } + setLoadingStates(prev => ({ ...prev, [loadingKey]: false })); + }; + + if (!queryId) { + return
Error: Stock Query ID is not configured.
; + } + + return ( +
+
+

Orders at Risk - Insufficient Stock

+

+ Orders in PAID or PENDING state with products having insufficient stock. +

+
+ +
+ {processedOrders.map((order) => { + const cancelLoadingKey = `cancel-${order.orderId}`; + const isCancelLoading = loadingStates[cancelLoadingKey]; + return ( +
+
+
+
+ +
+

{order.orderId}

+

Customer: {order.customerId}

+
+
+
+ + {order.orderStatus} + + +
+
+
+ +
+
+ {order.items.map((item, idx) => { + const severity = getStockSeverity(item.quantity, item.stockOnHand); + const backorderLoadingKey = `backorder-${item.productId}`; + const isBackorderLoading = loadingStates[backorderLoadingKey]; + return ( +
+
+
+
+
+

{item.productName}

+ ({item.productId}) +
+ +
+
+
+ Ordered: + {item.quantity} +
+
+ Stock: + {item.stockOnHand < 0 ? 0 : item.stockOnHand} +
+
+ Short: + + {item.quantity - (item.stockOnHand < 0 ? 0 : item.stockOnHand)} + +
+ +
+
+
+
+ ); + })} +
+
+
+ ); + })} + {processedOrders.length === 0 && ( +
+ No stock risk orders at the moment. +
+ )} +
+ + {(item: StockIssueItem) => { + currentRawItemsRef.current.push(item); + return null; + }} + + +
+ ); +} + +const RenderWatcher = ({ onRender }: { onRender: () => void }) => { + useEffect(() => { + const timeoutId = setTimeout(onRender, 0); + return () => clearTimeout(timeoutId); + }, [onRender]); + return null; +}; \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/config.ts b/tutorial/dapr/services/dashboard/src/config.ts new file mode 100644 index 0000000..c4c1761 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/config.ts @@ -0,0 +1,53 @@ +// Configuration helper to handle both build-time and runtime environment variables +declare global { + interface Window { + ENV?: { + VITE_SIGNALR_URL?: string + VITE_STOCK_QUERY_ID?: string + VITE_GOLD_QUERY_ID?: string + VITE_API_BASE_URL?: string + } + } +} + +export const getEnvVar = (key: string, defaultValue: string): string => { + // First check runtime config (injected by docker-entrypoint.sh) + if (typeof window !== 'undefined' && window.ENV) { + const value = window.ENV[key as keyof typeof window.ENV] + if (value) { + return value + } + } + + // Fall back to build-time env vars + const buildValue = import.meta.env[key] + if (buildValue) { + return buildValue + } + + return defaultValue +} + +// Helper to get the base URL from the current window location +const getBaseUrl = (): string => { + if (typeof window === 'undefined') { + return 'http://localhost' + } + + const { protocol, hostname, port } = window.location + // If running on a non-standard port (not 80/443), include it + if (port && port !== '80' && port !== '443') { + return `${protocol}//${hostname}:${port}` + } + return `${protocol}//${hostname}` +} + +// Use dynamic base URL if environment variables are not set +const baseUrl = getBaseUrl() + +export const config = { + signalrUrl: getEnvVar('VITE_SIGNALR_URL', `${baseUrl}/signalr/hub`), + stockQueryId: getEnvVar('VITE_STOCK_QUERY_ID', 'at-risk-orders-query'), + goldQueryId: getEnvVar('VITE_GOLD_QUERY_ID', 'delayed-gold-orders-query'), + apiBaseUrl: getEnvVar('VITE_API_BASE_URL', baseUrl) +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/index.css b/tutorial/dapr/services/dashboard/src/index.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/index.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/main.tsx b/tutorial/dapr/services/dashboard/src/main.tsx new file mode 100644 index 0000000..2fbbbc1 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/services/api.ts b/tutorial/dapr/services/dashboard/src/services/api.ts new file mode 100644 index 0000000..b457fce --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/services/api.ts @@ -0,0 +1,57 @@ +import axios, { AxiosError } from 'axios' +import { config } from '../config' + +const API_BASE_URL = config.apiBaseUrl + +interface ApiErrorResponse { + detail?: string; +} + +export const productsApi = { + incrementStock: async (productId: string, quantityToIncrement: number) => { + try { + const response = await axios.put(`${API_BASE_URL}/products-service/products/${productId}/increment`, { + quantity: quantityToIncrement + }); + console.log('Stock incremented successfully:', response.data); + return { success: true, data: response.data }; + } catch (error) { + console.error('Error incrementing stock:', error); + let errorMessage = 'An unknown error occurred'; + let errorDetail: string | undefined = undefined; + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + errorMessage = axiosError.message; + errorDetail = axiosError.response?.data?.detail; + } else if (error instanceof Error) { + errorMessage = error.message; + } + return { success: false, error: { message: errorMessage, detail: errorDetail || errorMessage } }; + } + } +}; + +export const ordersApi = { + cancelOrder: async (orderId: string) => { + try { + const response = await axios.put(`${API_BASE_URL}/orders-service/orders/${orderId}/status`, { + status: 'CANCELLED' // Directly send the new status + }); + console.log('Order cancelled successfully:', response.data); + return { success: true, data: response.data }; + } catch (error) { + console.error('Error cancelling order:', error); + let errorMessage = 'An unknown error occurred'; + let errorDetail: string | undefined = undefined; + + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + errorMessage = axiosError.message; + errorDetail = axiosError.response?.data?.detail; + } else if (error instanceof Error) { + errorMessage = error.message; + } + return { success: false, error: { message: errorMessage, detail: errorDetail || errorMessage } }; + } + } +}; \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/types.ts b/tutorial/dapr/services/dashboard/src/types.ts new file mode 100644 index 0000000..9607453 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/types.ts @@ -0,0 +1,18 @@ +export interface StockIssueItem { + orderId: string + customerId: string + orderStatus: string + productId: string + stockOnHand: number + quantity: number + productName: string +} + +export interface GoldCustomerDelay { + orderId: string + customerId: string + customerName: string + customerEmail: string + orderStatus: string + waitingSince: string +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/src/vite-env.d.ts b/tutorial/dapr/services/dashboard/src/vite-env.d.ts new file mode 100644 index 0000000..d4bf111 --- /dev/null +++ b/tutorial/dapr/services/dashboard/src/vite-env.d.ts @@ -0,0 +1,12 @@ +/// + +interface ImportMetaEnv { + readonly VITE_SIGNALR_URL: string + readonly VITE_STOCK_QUERY_ID: string + readonly VITE_GOLD_QUERY_ID: string + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/tailwind.config.js b/tutorial/dapr/services/dashboard/tailwind.config.js new file mode 100644 index 0000000..89a305e --- /dev/null +++ b/tutorial/dapr/services/dashboard/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/tsconfig.json b/tutorial/dapr/services/dashboard/tsconfig.json new file mode 100644 index 0000000..d0104ed --- /dev/null +++ b/tutorial/dapr/services/dashboard/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/tsconfig.node.json b/tutorial/dapr/services/dashboard/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/tutorial/dapr/services/dashboard/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/tutorial/dapr/services/dashboard/vite.config.ts b/tutorial/dapr/services/dashboard/vite.config.ts new file mode 100644 index 0000000..4ebbc2f --- /dev/null +++ b/tutorial/dapr/services/dashboard/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + base: '/dashboard/', + server: { + port: parseInt(process.env.VITE_PORT || '3000'), + host: true + } +}) \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/Dockerfile b/tutorial/dapr/services/notifications/Dockerfile new file mode 100644 index 0000000..9f5f377 --- /dev/null +++ b/tutorial/dapr/services/notifications/Dockerfile @@ -0,0 +1,46 @@ +# Build stage for UI +FROM node:20-alpine AS ui-builder + +WORKDIR /app/ui + +# Copy UI package files +COPY ui/package*.json ./ +RUN npm install + +# Copy UI source code +COPY ui/ . + +# Build the UI +RUN npm run build + +# Build stage for Python +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.13-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy Python code +COPY code/ . + +# Copy built UI from ui-builder +COPY --from=ui-builder /app/ui/dist ./ui/dist + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/code/main.py b/tutorial/dapr/services/notifications/code/main.py new file mode 100644 index 0000000..40c44c9 --- /dev/null +++ b/tutorial/dapr/services/notifications/code/main.py @@ -0,0 +1,570 @@ +import logging +import os +import json +import time +import uuid +import asyncio +from contextlib import asynccontextmanager +from datetime import datetime +from typing import Any, Dict, List, Set + +from fastapi import FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from dapr.ext.fastapi import DaprApp +from dapr.clients import DaprClient + +from models import ( + LowStockEvent, + CriticalStockEvent, + UnpackedDrasiEvent, + NotificationStatus, + NotificationResponse, + EventType, + NotificationEvent, + WebSocketMessage, + EventHistory +) + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global notification status tracking +notification_status = NotificationStatus() + +# Event history for UI +event_history = EventHistory(max_size=100) + +# WebSocket connection manager +class ConnectionManager: + def __init__(self): + self.active_connections: Set[WebSocket] = set() + self.max_connections = 10 # Reasonable limit now that connection leak is fixed + self._lock = asyncio.Lock() + + async def connect(self, websocket: WebSocket): + async with self._lock: + # Limit number of connections + if len(self.active_connections) >= self.max_connections: + logger.warning(f"Rejecting WebSocket connection: max connections ({self.max_connections}) reached") + await websocket.close(code=1008, reason="Too many connections") + return False + + await websocket.accept() + self.active_connections.add(websocket) + logger.info(f"WebSocket client connected. Total connections: {len(self.active_connections)}") + return True + + async def disconnect(self, websocket: WebSocket): + async with self._lock: + self.active_connections.discard(websocket) + logger.info(f"WebSocket client disconnected. Total connections: {len(self.active_connections)}") + + async def broadcast(self, message: dict): + """Send message to all connected clients.""" + if self.active_connections: + message_json = json.dumps(message) + disconnected = set() + + for connection in self.active_connections: + try: + await connection.send_text(message_json) + except Exception as e: + logger.error(f"Error sending message to client: {e}") + disconnected.add(connection) + + # Remove disconnected clients + self.active_connections -= disconnected + +manager = ConnectionManager() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info("Notifications service started") + logger.info("Subscribing to Dapr pub/sub topics:") + logger.info(" - low-stock-events") + logger.info(" - critical-stock-events") + # Clear event history on startup to avoid showing duplicates from previous runs + event_history.events.clear() + notification_status.reset() + logger.info("Event history cleared on startup") + yield + # Shutdown + logger.info("Notifications service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Notifications Service", + description="Handles stock alerts from Drasi queries via Dapr pub/sub", + version="1.0.0", + lifespan=lifespan +) + +# Note: Mount static files after all other routes are defined +# This will be done at the end of the file + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Create Dapr app +dapr_app = DaprApp(app) + + +def format_timestamp(ts_ms: int) -> str: + """Convert millisecond timestamp to readable format.""" + return datetime.fromtimestamp(ts_ms / 1000).strftime("%Y-%m-%d %H:%M:%S") + + +async def store_and_broadcast_event(event: NotificationEvent): + """Store event in history and broadcast to WebSocket clients.""" + # Add to history (remove oldest if at capacity) + if len(event_history.events) >= event_history.max_size: + event_history.events.pop(0) + event_history.events.append(event) + + # Broadcast to WebSocket clients + message = WebSocketMessage( + type="event", + data=event.dict() + ) + await manager.broadcast(message.dict()) + + +async def broadcast_stats(): + """Broadcast updated statistics to WebSocket clients.""" + message = WebSocketMessage( + type="stats", + data=notification_status.get_stats().dict() + ) + await manager.broadcast(message.dict()) + + +def process_low_stock_event(event_data: Dict[str, Any]) -> LowStockEvent: + """Process and validate low stock event data.""" + try: + # Extract the Drasi event from CloudEvent wrapper + drasi_data = event_data.get('data', event_data) + + # Handle unpacked Drasi event format + unpacked_event = UnpackedDrasiEvent(**drasi_data) + + # Skip initial state events (ts_ms = 0 or very old events) + if unpacked_event.ts_ms == 0: + logger.info(f"Skipping initial state event for low stock") + return None + + if unpacked_event.op == "i": # Insert operation + payload = unpacked_event.payload["after"] + elif unpacked_event.op == "u": # Update operation + payload = unpacked_event.payload["after"] + else: + raise ValueError(f"Unexpected operation type: {unpacked_event.op}") + + return LowStockEvent( + productId=payload["productId"], + productName=payload["productName"], + stockOnHand=payload["stockOnHand"], + lowStockThreshold=payload["lowStockThreshold"], + timestamp=format_timestamp(unpacked_event.ts_ms) + ) + except Exception as e: + logger.error(f"Error processing low stock event: {str(e)}") + logger.error(f"Event data: {json.dumps(event_data, indent=2)}") + raise + + +def process_critical_stock_event(event_data: Dict[str, Any]) -> CriticalStockEvent: + """Process and validate critical stock event data.""" + try: + # Extract the Drasi event from CloudEvent wrapper + drasi_data = event_data.get('data', event_data) + + # Handle unpacked Drasi event format + unpacked_event = UnpackedDrasiEvent(**drasi_data) + + # Skip initial state events (ts_ms = 0 or very old events) + if unpacked_event.ts_ms == 0: + logger.info(f"Skipping initial state event for critical stock") + return None + + if unpacked_event.op == "i": # Insert operation + payload = unpacked_event.payload["after"] + elif unpacked_event.op == "u": # Update operation + payload = unpacked_event.payload["after"] + else: + raise ValueError(f"Unexpected operation type: {unpacked_event.op}") + + return CriticalStockEvent( + productId=payload["productId"], + productName=payload["productName"], + productDescription=payload["productDescription"], + timestamp=format_timestamp(unpacked_event.ts_ms) + ) + except Exception as e: + logger.error(f"Error processing critical stock event: {str(e)}") + logger.error(f"Event data: {json.dumps(event_data, indent=2)}") + raise + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "notifications"} + + +@app.get("/status", response_model=NotificationResponse) +async def get_notification_status(): + """Get current notification processing status.""" + return NotificationResponse( + service="notifications", + status="active", + stats=notification_status.get_stats() + ) + + +@app.post("/reset-stats") +async def reset_stats(): + """Reset notification statistics.""" + notification_status.reset() + event_history.events.clear() + logger.info("Notification statistics reset") + await broadcast_stats() + return {"message": "Statistics reset successfully"} + + +@app.get("/history") +async def get_event_history(): + """Get recent notification events.""" + events_list = [] + for e in event_history.events: + try: + events_list.append(e.dict()) + except Exception as ex: + logger.error(f"Error serializing event in history endpoint: {ex}") + + return { + "events": events_list, + "total": len(events_list) + } + + +@app.get("/test-ui") +async def test_ui(): + """Test endpoint to check UI directory.""" + ui_dir = os.path.join(os.path.dirname(__file__), "ui", "dist") + exists = os.path.exists(ui_dir) + files = [] + if exists: + files = os.listdir(ui_dir) + return { + "ui_dir": ui_dir, + "exists": exists, + "files": files, + "cwd": os.getcwd(), + "file_location": __file__ + } + + +@app.websocket("/ws") +async def websocket_endpoint(websocket: WebSocket): + """WebSocket endpoint for real-time updates.""" + logger.info("WebSocket connection attempt received") + + # Try to connect (may be rejected if too many connections) + if not await manager.connect(websocket): + return + + # Send initial data + try: + # Send current stats + stats_msg = WebSocketMessage( + type="stats", + data=notification_status.get_stats().dict() + ) + logger.info(f"Sending initial stats: {stats_msg.dict()}") + await websocket.send_text(json.dumps(stats_msg.dict())) + + # Send recent events + try: + events_data = [] + for e in event_history.events: + try: + events_data.append(e.dict()) + except Exception as ex: + logger.error(f"Error serializing event {e.id}: {ex}") + + events_msg = WebSocketMessage( + type="history", + data={"events": events_data} + ) + logger.info(f"Sending event history: {len(events_data)} events") + msg_dict = events_msg.dict() + msg_json = json.dumps(msg_dict) + await websocket.send_text(msg_json) + except Exception as e: + logger.error(f"Error sending event history: {e}", exc_info=True) + + # Keep connection alive + while True: + # Wait for messages (ping/pong to keep alive) + try: + data = await websocket.receive_text() + logger.debug(f"Received WebSocket message: {data}") + if data == "ping": + # Send pong as JSON message + pong_msg = json.dumps({"type": "pong"}) + await websocket.send_text(pong_msg) + except Exception as e: + logger.error(f"Error receiving WebSocket message: {e}") + break + + except WebSocketDisconnect: + logger.info("WebSocket disconnected normally") + except Exception as e: + logger.error(f"WebSocket error: {e}", exc_info=True) + finally: + # Always disconnect when leaving the handler + manager.disconnect(websocket) + + + + +@dapr_app.subscribe(pubsub="notifications-pubsub", topic="low-stock-events") +async def handle_low_stock_event(event_data: dict): + """ + Handle low stock events from Drasi. + Simulates sending email to purchasing team. + """ + start_time = time.time() + + try: + # Process the event + event = process_low_stock_event(event_data) + + # Skip if this was an initial state event + if event is None: + logger.info("Skipped initial state event for low stock") + # Return SUCCESS status so Dapr ACKs the message + return {"status": "SUCCESS"} + + # Log the event details + logger.warning(f"LOW STOCK ALERT - Product: {event.productName} (ID: {event.productId})") + logger.warning(f" Current Stock: {event.stockOnHand}") + logger.warning(f" Low Stock Threshold: {event.lowStockThreshold}") + logger.warning(f" Timestamp: {event.timestamp}") + + # Simulate email notification + print("\n" + "="*70) + print("📧 EMAIL NOTIFICATION TO: purchasing@company.com") + print("="*70) + print(f"Subject: Low Stock Alert - {event.productName}") + print(f"\nDear Purchasing Team,") + print(f"\nThis is an automated alert to notify you that the following product") + print(f"has reached low stock levels and requires immediate attention:") + print(f"\nProduct Details:") + print(f" - Product ID: {event.productId}") + print(f" - Product Name: {event.productName}") + print(f" - Current Stock: {event.stockOnHand} units") + print(f" - Low Stock Threshold: {event.lowStockThreshold} units") + print(f" - Alert Time: {event.timestamp}") + print(f"\nRecommended Action:") + print(f" - Review current orders and forecast demand") + print(f" - Contact suppliers for restocking options") + print(f" - Place purchase order if necessary") + print(f"\nBest regards,") + print(f"Inventory Management System") + print("="*70 + "\n") + + # Store event for UI + notification_event = NotificationEvent( + id=str(uuid.uuid4()), + type=EventType.LOW_STOCK, + timestamp=datetime.utcnow(), + product_id=event.productId, + product_name=event.productName, + details={ + "stockOnHand": event.stockOnHand, + "lowStockThreshold": event.lowStockThreshold + }, + recipients=["purchasing@company.com"] + ) + await store_and_broadcast_event(notification_event) + + # Update statistics + notification_status.low_stock_count += 1 + notification_status.last_low_stock_event = event.timestamp + await broadcast_stats() + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Low stock event processed successfully in {elapsed:.2f}ms") + + # Return SUCCESS status so Dapr ACKs the message + return {"status": "SUCCESS"} + + except Exception as e: + logger.error(f"Failed to process low stock event: {str(e)}") + notification_status.error_count += 1 + # Return DROP status to ACK the message but indicate it was not processed + # This prevents infinite retries of malformed messages + return {"status": "DROP"} + + +@dapr_app.subscribe(pubsub="notifications-pubsub", topic="critical-stock-events") +async def handle_critical_stock_event(event_data: dict): + """ + Handle critical stock events from Drasi. + Simulates halting sales and notifying fulfillment team. + """ + start_time = time.time() + + try: + # Process the event + event = process_critical_stock_event(event_data) + + # Skip if this was an initial state event + if event is None: + logger.info("Skipped initial state event for critical stock") + # Return SUCCESS status so Dapr ACKs the message + return {"status": "SUCCESS"} + + # Log the critical event + logger.critical(f"CRITICAL STOCK ALERT - Product: {event.productName} (ID: {event.productId})") + logger.critical(f" Product is OUT OF STOCK!") + logger.critical(f" Timestamp: {event.timestamp}") + + # Simulate critical notifications + print("\n" + "="*70) + print("🚨 CRITICAL ALERT - OUT OF STOCK 🚨") + print("="*70) + + # Notification 1: Sales Team + print("\n📧 EMAIL NOTIFICATION TO: sales@company.com") + print(f"Subject: URGENT - Halt Sales for {event.productName}") + print(f"\nDear Sales Team,") + print(f"\nEFFECTIVE IMMEDIATELY: Please halt all sales for the following product") + print(f"as it is now completely OUT OF STOCK:") + print(f"\nProduct Details:") + print(f" - Product ID: {event.productId}") + print(f" - Product Name: {event.productName}") + print(f" - Description: {event.productDescription}") + print(f" - Stock Level: 0 units") + print(f" - Alert Time: {event.timestamp}") + print(f"\nRequired Actions:") + print(f" 1. Remove product from all active promotions") + print(f" 2. Update product status to 'Out of Stock' on website") + print(f" 3. Notify customers with pending orders") + print(f" 4. Do not accept new orders for this product") + + # Notification 2: Fulfillment Team + print("\n\n📧 EMAIL NOTIFICATION TO: fulfillment@company.com") + print(f"Subject: URGENT - Stock Depletion Alert for {event.productName}") + print(f"\nDear Fulfillment Team,") + print(f"\nThis is a critical alert regarding stock depletion:") + print(f"\nProduct Details:") + print(f" - Product ID: {event.productId}") + print(f" - Product Name: {event.productName}") + print(f" - Description: {event.productDescription}") + print(f" - Stock Level: 0 units") + print(f" - Alert Time: {event.timestamp}") + print(f"\nRequired Actions:") + print(f" 1. Review all pending orders containing this product") + print(f" 2. Identify orders that cannot be fulfilled") + print(f" 3. Prepare backorder notifications for affected customers") + print(f" 4. Coordinate with purchasing for emergency restocking") + + # System Actions Simulation + print("\n\n🤖 AUTOMATED SYSTEM ACTIONS:") + print(f" ✓ Product {event.productId} marked as 'Out of Stock' in catalog") + print(f" ✓ Sales channels notified to halt transactions") + print(f" ✓ Inventory system locked for this product") + print(f" ✓ Emergency restock request generated") + print("="*70 + "\n") + + # Store event for UI + notification_event = NotificationEvent( + id=str(uuid.uuid4()), + type=EventType.CRITICAL_STOCK, + timestamp=datetime.utcnow(), + product_id=event.productId, + product_name=event.productName, + details={ + "productDescription": event.productDescription, + "stockLevel": 0 + }, + recipients=["sales@company.com", "fulfillment@company.com"] + ) + await store_and_broadcast_event(notification_event) + + # Update statistics + notification_status.critical_stock_count += 1 + notification_status.last_critical_event = event.timestamp + await broadcast_stats() + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Critical stock event processed successfully in {elapsed:.2f}ms") + + # Return SUCCESS status so Dapr ACKs the message + return {"status": "SUCCESS"} + + except Exception as e: + logger.error(f"Failed to process critical stock event: {str(e)}") + notification_status.error_count += 1 + # Return DROP status to ACK the message but indicate it was not processed + # This prevents infinite retries of malformed messages + return {"status": "DROP"} + + +@app.get("/api") +async def api_info(): + """API endpoint with service information.""" + return { + "service": "notifications", + "version": "1.0.0", + "description": "Handles stock alerts from Drasi queries via Dapr pub/sub", + "endpoints": { + "health": "/health", + "status": "/status", + "reset_stats": "/reset-stats", + "ui": "/" + }, + "subscriptions": { + "low-stock-events": "Handles products reaching low stock threshold", + "critical-stock-events": "Handles products with zero stock" + } + } + + +# Serve static assets with proper MIME types +UI_DIR = os.path.join(os.path.dirname(__file__), "ui", "dist") +if os.path.exists(UI_DIR): + # Mount assets directory with proper MIME type handling + assets_dir = os.path.join(UI_DIR, "assets") + if os.path.exists(assets_dir): + # The ingress strips /notifications-service, so requests come as /assets/... + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + logger.info(f"Serving assets from {assets_dir} at /assets") + + # Mount the entire UI directory for HTML and other files + app.mount("/", StaticFiles(directory=UI_DIR, html=True), name="ui") + logger.info(f"Serving UI from {UI_DIR}") +else: + logger.warning(f"UI directory not found at {UI_DIR}") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/code/models.py b/tutorial/dapr/services/notifications/code/models.py new file mode 100644 index 0000000..cac6e14 --- /dev/null +++ b/tutorial/dapr/services/notifications/code/models.py @@ -0,0 +1,120 @@ +from pydantic import BaseModel, Field +from typing import Dict, Any, Optional, Union, List +from datetime import datetime +from enum import Enum + + +class LowStockEvent(BaseModel): + """Model for low stock event data.""" + productId: int = Field(..., description="Product identifier") + productName: str = Field(..., description="Product name") + stockOnHand: int = Field(..., description="Current stock level") + lowStockThreshold: int = Field(..., description="Low stock threshold") + timestamp: str = Field(..., description="Event timestamp") + + +class CriticalStockEvent(BaseModel): + """Model for critical stock (out of stock) event data.""" + productId: int = Field(..., description="Product identifier") + productName: str = Field(..., description="Product name") + productDescription: str = Field(..., description="Product description") + timestamp: str = Field(..., description="Event timestamp") + + +class UnpackedDrasiEvent(BaseModel): + """Model for Drasi unpacked event format.""" + op: str = Field(..., description="Operation type: i (insert), u (update), d (delete), x (control)") + ts_ms: int = Field(..., description="Timestamp in milliseconds") + seq: int = Field(..., description="Sequence number") + payload: Dict[str, Any] = Field(..., description="Event payload containing before/after states") + + +class NotificationStats(BaseModel): + """Statistics about processed notifications.""" + low_stock_count: int = Field(0, description="Number of low stock events processed") + critical_stock_count: int = Field(0, description="Number of critical stock events processed") + error_count: int = Field(0, description="Number of processing errors") + last_low_stock_event: Optional[str] = Field(None, description="Timestamp of last low stock event") + last_critical_event: Optional[str] = Field(None, description="Timestamp of last critical event") + + +class NotificationResponse(BaseModel): + """Response model for notification service status.""" + service: str = Field(..., description="Service name") + status: str = Field(..., description="Service status") + stats: NotificationStats = Field(..., description="Notification statistics") + + +class NotificationStatus: + """Track notification processing status.""" + def __init__(self): + self.low_stock_count = 0 + self.critical_stock_count = 0 + self.error_count = 0 + self.last_low_stock_event = None + self.last_critical_event = None + + def get_stats(self) -> NotificationStats: + """Get current statistics.""" + return NotificationStats( + low_stock_count=self.low_stock_count, + critical_stock_count=self.critical_stock_count, + error_count=self.error_count, + last_low_stock_event=self.last_low_stock_event, + last_critical_event=self.last_critical_event + ) + + def reset(self): + """Reset all statistics.""" + self.low_stock_count = 0 + self.critical_stock_count = 0 + self.error_count = 0 + self.last_low_stock_event = None + self.last_critical_event = None + + +class EventType(str, Enum): + """Types of notification events.""" + LOW_STOCK = "low_stock" + CRITICAL_STOCK = "critical_stock" + ERROR = "error" + + +class NotificationEvent(BaseModel): + """Model for storing notification events in history.""" + id: str = Field(..., description="Unique event ID") + type: EventType = Field(..., description="Type of event") + timestamp: datetime = Field(..., description="When the event occurred") + product_id: int = Field(..., description="Product ID") + product_name: str = Field(..., description="Product name") + details: Dict[str, Any] = Field(..., description="Event-specific details") + recipients: List[str] = Field(default_factory=list, description="Email recipients") + + def dict(self, **kwargs): + """Override dict to handle datetime and enum serialization.""" + d = super().dict(**kwargs) + if isinstance(d.get('timestamp'), datetime): + d['timestamp'] = d['timestamp'].isoformat() + if isinstance(d.get('type'), EventType): + d['type'] = d['type'].value + return d + + +class WebSocketMessage(BaseModel): + """Message format for WebSocket communications.""" + type: str = Field(..., description="Message type: event, stats, connected") + data: Any = Field(..., description="Message payload") + timestamp: datetime = Field(default_factory=datetime.utcnow, description="Message timestamp") + + def dict(self, **kwargs): + """Override dict to handle datetime serialization.""" + d = super().dict(**kwargs) + if isinstance(d.get('timestamp'), datetime): + d['timestamp'] = d['timestamp'].isoformat() + return d + + +class EventHistory(BaseModel): + """Container for event history.""" + events: List[NotificationEvent] = Field(default_factory=list, description="List of recent events") + max_size: int = Field(100, description="Maximum number of events to store") \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/k8s/dapr/pubsub-drasi.yaml b/tutorial/dapr/services/notifications/k8s/dapr/pubsub-drasi.yaml new file mode 100644 index 0000000..6532857 --- /dev/null +++ b/tutorial/dapr/services/notifications/k8s/dapr/pubsub-drasi.yaml @@ -0,0 +1,17 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: notifications-pubsub + namespace: drasi-system +spec: + type: pubsub.redis + version: v1 + metadata: + - name: redisHost + value: "notifications-redis.default.svc.cluster.local:6379" + - name: redisPassword + value: "" + - name: consumerID + value: "drasi-pubsub-reaction" + - name: enableTLS + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/k8s/dapr/pubsub.yaml b/tutorial/dapr/services/notifications/k8s/dapr/pubsub.yaml new file mode 100644 index 0000000..aea27ca --- /dev/null +++ b/tutorial/dapr/services/notifications/k8s/dapr/pubsub.yaml @@ -0,0 +1,17 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: notifications-pubsub + namespace: default +spec: + type: pubsub.redis + version: v1 + metadata: + - name: redisHost + value: "notifications-redis.default.svc.cluster.local:6379" + - name: redisPassword + value: "" + - name: consumerID + value: "notifications-service" + - name: enableTLS + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/k8s/deployment.yaml b/tutorial/dapr/services/notifications/k8s/deployment.yaml new file mode 100644 index 0000000..ff6c274 --- /dev/null +++ b/tutorial/dapr/services/notifications/k8s/deployment.yaml @@ -0,0 +1,94 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: notifications + labels: + app: notifications +spec: + replicas: 1 + selector: + matchLabels: + app: notifications + template: + metadata: + labels: + app: notifications + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "notifications" + dapr.io/app-port: "8000" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: notifications + image: ghcr.io/drasi-project/learning/dapr/notifications-service:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: notifications + labels: + app: notifications +spec: + selector: + app: notifications + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: notifications-stripprefix +spec: + stripPrefix: + prefixes: + - /notifications-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notifications + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-notifications-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /notifications-service + pathType: Prefix + backend: + service: + name: notifications + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/k8s/redis/redis.yaml b/tutorial/dapr/services/notifications/k8s/redis/redis.yaml new file mode 100644 index 0000000..8d11e73 --- /dev/null +++ b/tutorial/dapr/services/notifications/k8s/redis/redis.yaml @@ -0,0 +1,110 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: notifications-redis-config +data: + redis.conf: | + # Redis configuration for notifications service + bind 0.0.0.0 + protected-mode no + port 6379 + tcp-backlog 511 + timeout 0 + tcp-keepalive 300 + + # Persistence + save 900 1 + save 300 10 + save 60 10000 + stop-writes-on-bgsave-error yes + rdbcompression yes + rdbchecksum yes + dbfilename dump.rdb + dir /data + + # Logging + loglevel notice + logfile "" + + # Memory management + maxmemory 256mb + maxmemory-policy allkeys-lru +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: notifications-redis + labels: + app: notifications-redis +spec: + serviceName: notifications-redis + replicas: 1 + selector: + matchLabels: + app: notifications-redis + template: + metadata: + labels: + app: notifications-redis + spec: + containers: + - name: redis + image: redis:7-alpine + command: + - redis-server + - /usr/local/etc/redis/redis.conf + ports: + - containerPort: 6379 + name: redis + volumeMounts: + - name: redis-data + mountPath: /data + - name: redis-config + mountPath: /usr/local/etc/redis + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" + livenessProbe: + tcpSocket: + port: redis + initialDelaySeconds: 30 + periodSeconds: 10 + readinessProbe: + exec: + command: + - redis-cli + - ping + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: redis-config + configMap: + name: notifications-redis-config + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: notifications-redis + labels: + app: notifications-redis +spec: + type: ClusterIP + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP + name: redis + selector: + app: notifications-redis \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/requirements.txt b/tutorial/dapr/services/notifications/requirements.txt new file mode 100644 index 0000000..3d98622 --- /dev/null +++ b/tutorial/dapr/services/notifications/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.115.5 +uvicorn[standard]==0.24.0 +pydantic==2.10.5 +dapr==1.15.0 +dapr-ext-fastapi==1.15.0 +python-json-logger==2.0.7 +websockets==13.1 \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/setup/test-apis.sh b/tutorial/dapr/services/notifications/setup/test-apis.sh new file mode 100755 index 0000000..60e8d27 --- /dev/null +++ b/tutorial/dapr/services/notifications/setup/test-apis.sh @@ -0,0 +1,103 @@ +#!/bin/bash + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Check if kubectl port-forward is needed +SERVICE_URL="http://localhost:8001/notifications-service" + +echo -e "${BLUE}Testing Notifications Service APIs${NC}" +echo -e "${BLUE}=================================${NC}\n" + +# Test 1: Health Check +echo -e "${YELLOW}Test 1: Health Check${NC}" +echo "GET $SERVICE_URL/health" +RESPONSE=$(curl -s -w "\n%{http_code}" $SERVICE_URL/health) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n1) + +if [ "$HTTP_CODE" == "200" ]; then + echo -e "${GREEN}✓ Health check passed${NC}" + echo "Response: $BODY" +else + echo -e "${RED}✗ Health check failed (HTTP $HTTP_CODE)${NC}" + echo "Response: $BODY" +fi +echo + +# Test 2: Get Service Info +echo -e "${YELLOW}Test 2: Get Service Info${NC}" +echo "GET $SERVICE_URL/" +RESPONSE=$(curl -s -w "\n%{http_code}" $SERVICE_URL/) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n1) + +if [ "$HTTP_CODE" == "200" ]; then + echo -e "${GREEN}✓ Service info retrieved${NC}" + echo "Response: $BODY" | jq '.' 2>/dev/null || echo "$BODY" +else + echo -e "${RED}✗ Failed to get service info (HTTP $HTTP_CODE)${NC}" + echo "Response: $BODY" +fi +echo + +# Test 3: Get Notification Status +echo -e "${YELLOW}Test 3: Get Notification Status${NC}" +echo "GET $SERVICE_URL/status" +RESPONSE=$(curl -s -w "\n%{http_code}" $SERVICE_URL/status) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n1) + +if [ "$HTTP_CODE" == "200" ]; then + echo -e "${GREEN}✓ Status retrieved successfully${NC}" + echo "Response: $BODY" | jq '.' 2>/dev/null || echo "$BODY" +else + echo -e "${RED}✗ Failed to get status (HTTP $HTTP_CODE)${NC}" + echo "Response: $BODY" +fi +echo + +# Test 4: Reset Statistics +echo -e "${YELLOW}Test 4: Reset Statistics${NC}" +echo "POST $SERVICE_URL/reset-stats" +RESPONSE=$(curl -s -X POST -w "\n%{http_code}" $SERVICE_URL/reset-stats) +HTTP_CODE=$(echo "$RESPONSE" | tail -n1) +BODY=$(echo "$RESPONSE" | head -n1) + +if [ "$HTTP_CODE" == "200" ]; then + echo -e "${GREEN}✓ Statistics reset successfully${NC}" + echo "Response: $BODY" +else + echo -e "${RED}✗ Failed to reset statistics (HTTP $HTTP_CODE)${NC}" + echo "Response: $BODY" +fi +echo + +# Instructions for monitoring events +echo -e "${BLUE}=================================${NC}" +echo -e "${BLUE}Event Monitoring Instructions${NC}" +echo -e "${BLUE}=================================${NC}\n" + +echo -e "${YELLOW}To monitor notifications in real-time:${NC}" +echo "1. Check logs: kubectl logs -l app=notifications -f" +echo "2. The service will log notifications when stock events are received" +echo "" +echo -e "${YELLOW}To trigger stock events:${NC}" +echo "1. Update product stock levels via the products service API" +echo "2. Set stock <= lowStockThreshold to trigger low stock events" +echo "3. Set stock = 0 to trigger critical stock events" +echo "" +echo -e "${YELLOW}Example commands:${NC}" +echo "# Trigger low stock event (assuming product 1 has lowStockThreshold of 10)" +echo "curl -X PUT http://localhost:8001/products-service/products/1/decrement \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"quantity\": 95}'" +echo "" +echo "# Trigger critical stock event" +echo "curl -X PUT http://localhost:8001/products-service/products/1/decrement \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"quantity\": 5}'" \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/setup/test-ui.sh b/tutorial/dapr/services/notifications/setup/test-ui.sh new file mode 100755 index 0000000..47bf153 --- /dev/null +++ b/tutorial/dapr/services/notifications/setup/test-ui.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Test UI for notifications service + +echo "=== Testing Notifications UI ===" +echo "" +echo "The UI is available at: http://localhost/notifications-service/ui/" +echo "" +echo "To trigger events, use the products service to create low/critical stock situations:" +echo "" +echo "1. Create a product with low stock:" +echo " curl -X POST http://localhost/products-service/products \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -d '{\"productId\": 5001, \"productName\": \"Test Product\", \"productDescription\": \"For testing\", \"stockOnHand\": 25, \"lowStockThreshold\": 20}'" +echo "" +echo "2. Trigger low stock event:" +echo " curl -X PUT http://localhost/products-service/products/5001/decrement \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -d '{\"quantity\": 10}'" +echo "" +echo "3. Trigger critical stock event:" +echo " curl -X PUT http://localhost/products-service/products/5001/decrement \\" +echo " -H \"Content-Type: application/json\" \\" +echo " -d '{\"quantity\": 15}'" +echo "" +echo "Watch the UI to see real-time notifications with email animations!" \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/.gitignore b/tutorial/dapr/services/notifications/ui/.gitignore new file mode 100644 index 0000000..981da92 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +*.log \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/index.html b/tutorial/dapr/services/notifications/ui/index.html new file mode 100644 index 0000000..3956ee8 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Notifications Dashboard - Drasi Demo + + +
+ + + \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/package-lock.json b/tutorial/dapr/services/notifications/ui/package-lock.json new file mode 100644 index 0000000..7b23e55 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/package-lock.json @@ -0,0 +1,3126 @@ +{ + "name": "notifications-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "notifications-ui", + "version": "0.0.0", + "dependencies": { + "date-fns": "^3.0.6", + "framer-motion": "^11.0.0", + "lucide-react": "^0.312.0", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "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.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.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-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "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/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "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.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz", + "integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz", + "integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz", + "integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz", + "integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz", + "integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz", + "integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz", + "integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz", + "integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz", + "integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz", + "integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz", + "integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz", + "integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz", + "integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz", + "integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz", + "integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz", + "integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz", + "integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz", + "integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz", + "integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz", + "integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==", + "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.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.23", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz", + "integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@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 || ^7.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "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/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "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.30001733", + "electron-to-chromium": "^1.5.199", + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/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/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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 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/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/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.204", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.204.tgz", + "integrity": "sha512-s9VbBXWxfDrl67PlO4avwh0/GU2vcwx8Fph3wlR8LJl7ySGYId59EFE17VWVcuC3sLWNPENm6Z/uGqKbkPCcXA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/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/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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "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==", + "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/lucide-react": { + "version": "0.312.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.312.0.tgz", + "integrity": "sha512-3UZsqyswRXjW4t+nw+InICewSimjPKHuSxiFYqTshv9xkK3tPPntXk/lvXc9pKlXIxm3v9WKyoxcrB6YHhP+dg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "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/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/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "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/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/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "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/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "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/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.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==", + "dev": true, + "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/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.46.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz", + "integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.3", + "@rollup/rollup-android-arm64": "4.46.3", + "@rollup/rollup-darwin-arm64": "4.46.3", + "@rollup/rollup-darwin-x64": "4.46.3", + "@rollup/rollup-freebsd-arm64": "4.46.3", + "@rollup/rollup-freebsd-x64": "4.46.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", + "@rollup/rollup-linux-arm-musleabihf": "4.46.3", + "@rollup/rollup-linux-arm64-gnu": "4.46.3", + "@rollup/rollup-linux-arm64-musl": "4.46.3", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", + "@rollup/rollup-linux-ppc64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-musl": "4.46.3", + "@rollup/rollup-linux-s390x-gnu": "4.46.3", + "@rollup/rollup-linux-x64-gnu": "4.46.3", + "@rollup/rollup-linux-x64-musl": "4.46.3", + "@rollup/rollup-win32-arm64-msvc": "4.46.3", + "@rollup/rollup-win32-ia32-msvc": "4.46.3", + "@rollup/rollup-win32-x64-msvc": "4.46.3", + "fsevents": "~2.3.2" + } + }, + "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/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "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/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.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", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "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.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + } + } +} diff --git a/tutorial/dapr/services/notifications/ui/package.json b/tutorial/dapr/services/notifications/ui/package.json new file mode 100644 index 0000000..5cca798 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "notifications-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "framer-motion": "^11.0.0", + "lucide-react": "^0.312.0", + "date-fns": "^3.0.6" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "tailwindcss": "^3.4.0", + "typescript": "^5.2.2", + "vite": "^5.0.8" + } +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/postcss.config.js b/tutorial/dapr/services/notifications/ui/postcss.config.js new file mode 100644 index 0000000..e99ebc2 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/App.tsx b/tutorial/dapr/services/notifications/ui/src/App.tsx new file mode 100644 index 0000000..901c408 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/App.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Bell, RefreshCw, Volume2, VolumeX } from 'lucide-react'; +import { useWebSocket } from './hooks/useWebSocket'; +import { EventCard } from './components/EventCard'; +import { StatsCard } from './components/StatsCard'; +import { ConnectionStatus } from './components/ConnectionStatus'; +import { NotificationEvent, NotificationStats, WebSocketMessage } from './types'; + +// Determine WebSocket URL based on environment +const getWebSocketUrl = () => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + + if (window.location.hostname === 'localhost' && window.location.port === '3000') { + return 'ws://localhost:8000/ws'; + } + + // When served through ingress, use the prefix + return `${protocol}//${host}/notifications-service/ws`; +}; + +function App() { + const [events, setEvents] = useState([]); + const [stats, setStats] = useState({ + low_stock_count: 0, + critical_stock_count: 0, + error_count: 0, + last_low_stock_event: null, + last_critical_event: null, + }); + const [soundEnabled, setSoundEnabled] = useState(true); + const audioRef = useRef(null); + + const handleWebSocketMessage = useCallback((message: WebSocketMessage) => { + switch (message.type) { + case 'event': + const newEvent = message.data as NotificationEvent; + setEvents(prev => [newEvent, ...prev].slice(0, 50)); // Keep last 50 events + + // Play sound for critical events + if (soundEnabled && newEvent.type === 'critical_stock' && audioRef.current) { + audioRef.current.play().catch(e => console.error('Failed to play sound:', e)); + } + break; + + case 'stats': + setStats(message.data as NotificationStats); + break; + + case 'history': + setEvents(message.data.events.reverse()); // Reverse to show newest first + break; + } + }, [soundEnabled]); + + const { isConnected, error } = useWebSocket({ + url: getWebSocketUrl(), + onMessage: handleWebSocketMessage, + }); + + const handleReset = async () => { + try { + const response = await fetch('/notifications-service/reset-stats', { + method: 'POST', + }); + + if (response.ok) { + setEvents([]); + } + } catch (err) { + console.error('Failed to reset stats:', err); + } + }; + + // Create notification sound + useEffect(() => { + const audio = new Audio('data:audio/wav;base64,UklGRmQFAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAFAACfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8AnwCfAJ8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AP8A/wD/AOAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAA=='); + audioRef.current = audio; + }, []); + + return ( +
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/components/ConnectionStatus.tsx b/tutorial/dapr/services/notifications/ui/src/components/ConnectionStatus.tsx new file mode 100644 index 0000000..9473fa0 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/components/ConnectionStatus.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Wifi, WifiOff } from 'lucide-react'; + +interface ConnectionStatusProps { + isConnected: boolean; + error: string | null; +} + +export const ConnectionStatus: React.FC = ({ isConnected, error }) => { + return ( + + {isConnected ? ( + <> + + Connected + + ) : ( + <> + + {error || 'Disconnected'} + + )} + + ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/components/EmailAnimation.tsx b/tutorial/dapr/services/notifications/ui/src/components/EmailAnimation.tsx new file mode 100644 index 0000000..c94b5ae --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/components/EmailAnimation.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Mail, Send, CheckCircle } from 'lucide-react'; + +interface EmailAnimationProps { + recipients: string[]; + subject: string; + onComplete?: () => void; +} + +export const EmailAnimation: React.FC = ({ recipients, subject, onComplete }) => { + const [stage, setStage] = useState<'compose' | 'sending' | 'sent'>('compose'); + + useEffect(() => { + const timer1 = setTimeout(() => setStage('sending'), 500); + const timer2 = setTimeout(() => setStage('sent'), 2000); + const timer3 = setTimeout(() => { + onComplete?.(); + }, 3000); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + clearTimeout(timer3); + }; + }, [onComplete]); + + return ( +
+ + {stage === 'compose' && ( + +
+ + New Email +
+
+

To: {recipients[0]}

+

Subject: {subject}

+
+
+ )} + + {stage === 'sending' && ( + +
+ +
+ + + )} + + {stage === 'sent' && ( + + + Email Sent! + + )} +
+
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/components/EventCard.tsx b/tutorial/dapr/services/notifications/ui/src/components/EventCard.tsx new file mode 100644 index 0000000..77c8c7a --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/components/EventCard.tsx @@ -0,0 +1,110 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { Package, AlertTriangle, AlertCircle, Mail, Clock } from 'lucide-react'; +import { NotificationEvent } from '../types'; +import { EmailAnimation } from './EmailAnimation'; +import { format } from 'date-fns'; + +interface EventCardProps { + event: NotificationEvent; + index: number; +} + +export const EventCard: React.FC = ({ event, index }) => { + const [showEmail, setShowEmail] = useState(true); + + const getEventIcon = () => { + switch (event.type) { + case 'low_stock': + return ; + case 'critical_stock': + return ; + default: + return ; + } + }; + + const getEventColor = () => { + switch (event.type) { + case 'low_stock': + return 'border-yellow-200 bg-yellow-50'; + case 'critical_stock': + return 'border-red-200 bg-red-50'; + default: + return 'border-gray-200 bg-gray-50'; + } + }; + + const getSubject = () => { + switch (event.type) { + case 'low_stock': + return `Low Stock Alert - ${event.product_name}`; + case 'critical_stock': + return `URGENT - Out of Stock: ${event.product_name}`; + default: + return 'Notification'; + } + }; + + return ( + +
+
+ {getEventIcon()} +
+

{event.product_name}

+

Product ID: {event.product_id}

+
+
+
+ + {format(new Date(event.timestamp), 'HH:mm:ss')} +
+
+ +
+ {event.type === 'low_stock' && ( +
+ Stock: {event.details.stockOnHand} + Threshold: {event.details.lowStockThreshold} +
+ )} + {event.type === 'critical_stock' && ( +
+ OUT OF STOCK +

{event.details.productDescription}

+
+ )} +
+ + {showEmail && ( +
+
+ + Sending notifications to: +
+
+ {event.recipients.join(', ')} +
+ setShowEmail(false)} + /> +
+ )} + + {!showEmail && ( +
+ + Email notifications sent to {event.recipients.length} recipient(s) +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/components/StatsCard.tsx b/tutorial/dapr/services/notifications/ui/src/components/StatsCard.tsx new file mode 100644 index 0000000..34f4e6f --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/components/StatsCard.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { AlertCircle, AlertTriangle, XCircle } from 'lucide-react'; +import { NotificationStats } from '../types'; + +interface StatsCardProps { + stats: NotificationStats; +} + +export const StatsCard: React.FC = ({ stats }) => { + return ( +
+ +
+
+

Low Stock Events

+

{stats.low_stock_count}

+
+ +
+ {stats.last_low_stock_event && ( +

+ Last: {new Date(stats.last_low_stock_event).toLocaleTimeString()} +

+ )} +
+ + +
+
+

Critical Stock Events

+

{stats.critical_stock_count}

+
+ +
+ {stats.last_critical_event && ( +

+ Last: {new Date(stats.last_critical_event).toLocaleTimeString()} +

+ )} +
+ + +
+
+

Processing Errors

+

{stats.error_count}

+
+ +
+

+ Total Events: {stats.low_stock_count + stats.critical_stock_count} +

+
+
+ ); +}; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/hooks/useWebSocket.ts b/tutorial/dapr/services/notifications/ui/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..d02df12 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/hooks/useWebSocket.ts @@ -0,0 +1,125 @@ +import { useEffect, useRef, useState } from 'react'; +import { WebSocketMessage } from '../types'; + +interface UseWebSocketProps { + url: string; + onMessage?: (message: WebSocketMessage) => void; + reconnectInterval?: number; +} + +export const useWebSocket = ({ url, onMessage, reconnectInterval = 30000 }: UseWebSocketProps) => { + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const wsRef = useRef(null); + const reconnectTimeoutRef = useRef | null>(null); + const onMessageRef = useRef(onMessage); + + // Update the ref when onMessage changes + useEffect(() => { + onMessageRef.current = onMessage; + }, [onMessage]); + + const connect = () => { + try { + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + console.log('WebSocket connected'); + setIsConnected(true); + setError(null); + + // Clear any reconnect timeout + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data) as WebSocketMessage; + // Ignore pong messages + if (message.type === 'pong') { + return; + } + onMessageRef.current?.(message); + } catch (err) { + console.error('Failed to parse WebSocket message:', err); + } + }; + + ws.onerror = (event) => { + console.error('WebSocket error:', event); + setError('Connection error'); + }; + + ws.onclose = (event) => { + console.log('WebSocket disconnected', event.code, event.reason); + setIsConnected(false); + wsRef.current = null; + + // Don't reconnect if connection was rejected due to too many connections + if (event.code === 1008) { + setError('Too many connections. Please close other tabs or wait before reconnecting.'); + // Use a longer delay before attempting to reconnect + reconnectTimeoutRef.current = setTimeout(() => { + console.log('Attempting to reconnect after connection limit...'); + connect(); + }, reconnectInterval * 2); + } else { + // Normal reconnect for other disconnection reasons + reconnectTimeoutRef.current = setTimeout(() => { + console.log('Attempting to reconnect...'); + connect(); + }, reconnectInterval); + } + }; + + // Send periodic pings to keep connection alive + const pingInterval = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send('ping'); + } + }, 30000); + + // Store interval ID on the WebSocket instance for cleanup + (ws as any).pingInterval = pingInterval; + + } catch (err) { + console.error('Failed to connect WebSocket:', err); + setError('Failed to connect'); + + // Retry connection + reconnectTimeoutRef.current = setTimeout(connect, reconnectInterval); + } + }; + + const disconnect = () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + if (wsRef.current) { + const ws = wsRef.current; + + // Clear ping interval + if ((ws as any).pingInterval) { + clearInterval((ws as any).pingInterval); + } + + ws.close(); + wsRef.current = null; + } + }; + + useEffect(() => { + connect(); + return () => { + disconnect(); + }; + }, [url]); // Only reconnect when URL changes + + return { isConnected, error }; +}; \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/index.css b/tutorial/dapr/services/notifications/ui/src/index.css new file mode 100644 index 0000000..aa6da2d --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/index.css @@ -0,0 +1,32 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Custom animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.animate-slide-in { + animation: slideIn 0.5s ease-out; +} + +.animate-fade-in { + animation: fadeIn 0.3s ease-out; +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/main.tsx b/tutorial/dapr/services/notifications/ui/src/main.tsx new file mode 100644 index 0000000..2fbbbc1 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/types.ts b/tutorial/dapr/services/notifications/ui/src/types.ts new file mode 100644 index 0000000..2f22a68 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/types.ts @@ -0,0 +1,23 @@ +export interface NotificationEvent { + id: string; + type: 'low_stock' | 'critical_stock' | 'error'; + timestamp: string; + product_id: number; + product_name: string; + details: Record; + recipients: string[]; +} + +export interface NotificationStats { + low_stock_count: number; + critical_stock_count: number; + error_count: number; + last_low_stock_event: string | null; + last_critical_event: string | null; +} + +export interface WebSocketMessage { + type: 'event' | 'stats' | 'history' | 'connected' | 'pong'; + data: any; + timestamp: string; +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/src/vite-env.d.ts b/tutorial/dapr/services/notifications/ui/src/vite-env.d.ts new file mode 100644 index 0000000..151aa68 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/tailwind.config.js b/tutorial/dapr/services/notifications/ui/tailwind.config.js new file mode 100644 index 0000000..89a305e --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/tailwind.config.js @@ -0,0 +1,11 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/tsconfig.json b/tutorial/dapr/services/notifications/ui/tsconfig.json new file mode 100644 index 0000000..7a7611e --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/tsconfig.node.json b/tutorial/dapr/services/notifications/ui/tsconfig.node.json new file mode 100644 index 0000000..099658c --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/tutorial/dapr/services/notifications/ui/vite.config.ts b/tutorial/dapr/services/notifications/ui/vite.config.ts new file mode 100644 index 0000000..9733697 --- /dev/null +++ b/tutorial/dapr/services/notifications/ui/vite.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + base: '/notifications-service/', + build: { + outDir: 'dist' + }, + server: { + port: 3000, + proxy: { + '/ws': { + target: 'ws://localhost:8000', + ws: true + }, + '/api': { + target: 'http://localhost:8000' + }, + '/reset-stats': { + target: 'http://localhost:8000' + } + } + } +}) \ No newline at end of file diff --git a/tutorial/dapr/services/orders/Dockerfile b/tutorial/dapr/services/orders/Dockerfile new file mode 100644 index 0000000..c8cd6c7 --- /dev/null +++ b/tutorial/dapr/services/orders/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.13-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY code/ . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/tutorial/dapr/services/orders/code/dapr_client.py b/tutorial/dapr/services/orders/code/dapr_client.py new file mode 100644 index 0000000..cf185b0 --- /dev/null +++ b/tutorial/dapr/services/orders/code/dapr_client.py @@ -0,0 +1,119 @@ +import json +import logging +import os +import base64 +from typing import Optional, Any, List, Dict +from dapr.clients import DaprClient + +logger = logging.getLogger(__name__) + + +class DaprStateStore: + def __init__(self, store_name: Optional[str] = None): + self.store_name = store_name or os.getenv("DAPR_STORE_NAME", "orders-store") + self.client = DaprClient() + logger.info(f"Initialized Dapr state store client for store: {self.store_name}") + + async def get_item(self, key: str) -> Optional[dict]: + """Get an item from the state store.""" + try: + response = self.client.get_state( + store_name=self.store_name, + key=key + ) + + if response.data: + data = json.loads(response.data) + logger.debug(f"Retrieved item with key '{key}': {data}") + return data + else: + logger.debug(f"No item found with key '{key}'") + return None + + except Exception as e: + logger.error(f"Error getting item with key '{key}': {str(e)}") + raise + + async def save_item(self, key: str, data: dict) -> None: + """Save an item to the state store.""" + try: + self.client.save_state( + store_name=self.store_name, + key=key, + value=json.dumps(data) + ) + logger.debug(f"Saved item with key '{key}': {data}") + + except Exception as e: + logger.error(f"Error saving item with key '{key}': {str(e)}") + raise + + async def delete_item(self, key: str) -> None: + """Delete an item from the state store.""" + try: + self.client.delete_state( + store_name=self.store_name, + key=key + ) + logger.debug(f"Deleted item with key '{key}'") + + except Exception as e: + logger.error(f"Error deleting item with key '{key}': {str(e)}") + raise + + async def query_items(self, query: Dict[str, Any]) -> tuple[List[Dict[str, Any]], Optional[str]]: + """ + Query items from the state store using Dapr state query API. + + Args: + query: Query dictionary with filter, sort, and page options + + Returns: + Tuple of (results list, pagination token) + """ + try: + query_json = json.dumps(query) + logger.debug(f"Executing state query with: {query_json}") + response = self.client.query_state( + store_name=self.store_name, + query=query_json + ) + + results = [] + for item in response.results: + try: + # The value might already be a string (JSON), not bytes + if hasattr(item.value, 'decode'): + # It's bytes, decode it + value_str = item.value.decode('UTF-8') + else: + # It's already a string + value_str = item.value + + # Parse the JSON string + value = json.loads(value_str) + + # If the value is a string, it might be base64 encoded JSON + if isinstance(value, str): + try: + # Try base64 decoding + decoded_bytes = base64.b64decode(value) + decoded_str = decoded_bytes.decode('utf-8') + value = json.loads(decoded_str) + except Exception: + # Keep the original string value if base64 decode fails + pass + + results.append({ + 'key': item.key, + 'value': value + }) + except Exception as e: + logger.error(f"Failed to parse item with key {item.key}: {e}") + + logger.debug(f"Query completed - returned {len(results)} items") + return results, response.token + + except Exception as e: + logger.error(f"Error querying state store: {str(e)}") + raise \ No newline at end of file diff --git a/tutorial/dapr/services/orders/code/main.py b/tutorial/dapr/services/orders/code/main.py new file mode 100644 index 0000000..0f3f3ab --- /dev/null +++ b/tutorial/dapr/services/orders/code/main.py @@ -0,0 +1,300 @@ +import logging +import os +import time +from contextlib import asynccontextmanager +from typing import Optional +import random + +from fastapi import FastAPI, HTTPException, Depends, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from models import Order, OrderCreateRequest, OrderStatusUpdateRequest, OrderResponse, OrderListResponse, OrderStatus, OrderItem +from dapr_client import DaprStateStore + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global state store instance +state_store = None + + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global state_store + state_store = DaprStateStore() + logger.info("Orders service started") + yield + # Shutdown + logger.info("Orders service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Orders Service", + description="Manages customer orders with Dapr state store", + version="1.0.0", + lifespan=lifespan, + root_path="/orders-service" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_state_store() -> DaprStateStore: + """Dependency to get the state store instance.""" + if state_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="State store not initialized" + ) + return state_store + + + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "orders"} + + +@app.get("/orders", response_model=OrderListResponse) +async def list_orders( + store: DaprStateStore = Depends(get_state_store) +): + """Get all orders.""" + start_time = time.time() + + try: + # Simple query with empty filter to get all items + query = { + "filter": {} + } + + # Execute the query + results, _ = await store.query_items(query) + + # Convert results to OrderResponse objects + items = [] + for result in results: + try: + order = Order.from_db_dict(result['value']) + items.append(OrderResponse.from_order(order)) + except Exception as e: + logger.warning(f"Failed to parse item with key {result['key']}: {str(e)}") + continue + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved {len(items)} orders in {elapsed:.2f}ms") + + return OrderListResponse(items=items, total=len(items)) + + except Exception as e: + elapsed = (time.time() - start_time) * 1000 + logger.error(f"Failed to list orders after {elapsed:.2f}ms: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list orders: {str(e)}" + ) + + +@app.post("/orders", response_model=OrderResponse, status_code=status.HTTP_201_CREATED) +async def create_order( + request: OrderCreateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Create a new order.""" + start_time = time.time() + + try: + # Use provided order ID or generate a unique one + if request.orderId: + order_id = request.orderId + # Check if ID already exists + existing = await store.get_item(str(order_id)) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Order with ID {order_id} already exists" + ) + else: + # Generate a unique order ID + order_id = random.randint(3001, 999999) + # Check if ID already exists + existing = await store.get_item(str(order_id)) + while existing: + order_id = random.randint(3001, 999999) + existing = await store.get_item(str(order_id)) + + # Convert request items to OrderItem objects + order_items = [ + OrderItem(productId=item.productId, quantity=item.quantity) + for item in request.items + ] + + # Create order + order = Order( + orderId=order_id, + customerId=request.customerId, + items=order_items, + status=OrderStatus.PENDING + ) + + # Save to state store + await store.save_item(str(order_id), order.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Created order {order_id} for customer {request.customerId} in {elapsed:.2f}ms") + + return OrderResponse.from_order(order) + + except Exception as e: + logger.error(f"Error creating order: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create order: {str(e)}" + ) + + +@app.get("/orders/{order_id}", response_model=OrderResponse) +async def get_order( + order_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Retrieve order details.""" + start_time = time.time() + + try: + data = await store.get_item(str(order_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Order {order_id} not found" + ) + + order = Order.from_db_dict(data) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved order {order_id} in {elapsed:.2f}ms") + + return OrderResponse.from_order(order) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving order: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve order: {str(e)}" + ) + + +@app.put("/orders/{order_id}/status", response_model=OrderResponse) +async def update_order_status( + order_id: int, + request: OrderStatusUpdateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Update the status of an order.""" + start_time = time.time() + + try: + # Get current order + data = await store.get_item(str(order_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Order {order_id} not found" + ) + + order = Order.from_db_dict(data) + + # Validate status transition (basic validation) + if order.status == OrderStatus.DELIVERED and request.status != OrderStatus.DELIVERED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot change status of a delivered order" + ) + + if order.status == OrderStatus.CANCELLED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot change status of a cancelled order" + ) + + # Update status + order.status = request.status + + # Save back to state store + await store.save_item(str(order_id), order.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Updated order {order_id} status to {request.status} in {elapsed:.2f}ms") + + return OrderResponse.from_order(order) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating order status: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update order status: {str(e)}" + ) + + +@app.delete("/orders/{order_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_order( + order_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Delete an order.""" + start_time = time.time() + + try: + # Check if order exists + data = await store.get_item(str(order_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Order {order_id} not found" + ) + + # Delete from state store + await store.delete_item(str(order_id)) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Deleted order {order_id} in {elapsed:.2f}ms") + + return None + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting order: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete order: {str(e)}" + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/orders/code/models.py b/tutorial/dapr/services/orders/code/models.py new file mode 100644 index 0000000..1713a39 --- /dev/null +++ b/tutorial/dapr/services/orders/code/models.py @@ -0,0 +1,102 @@ +from pydantic import BaseModel, Field, validator +from typing import List, Optional +from enum import Enum +import uuid + + +class OrderStatus(str, Enum): + PENDING = "PENDING" + PAID = "PAID" + PROCESSING = "PROCESSING" + SHIPPED = "SHIPPED" + DELIVERED = "DELIVERED" + CANCELLED = "CANCELLED" + + +class OrderItemRequest(BaseModel): + productId: int = Field(..., description="Product ID") + quantity: int = Field(..., gt=0, description="Quantity ordered") + + +class OrderItem(BaseModel): + productId: int = Field(..., description="Product ID") + quantity: int = Field(..., gt=0, description="Quantity ordered") + + def to_db_dict(self) -> dict: + """Convert to database format with snake_case.""" + return { + "product_id": self.productId, + "quantity": self.quantity + } + + @classmethod + def from_db_dict(cls, data: dict) -> "OrderItem": + """Create from database format with snake_case.""" + return cls( + productId=data["product_id"], + quantity=data["quantity"] + ) + + +class Order(BaseModel): + orderId: int = Field(..., description="Unique order identifier") + customerId: int = Field(..., description="Customer ID who placed the order") + items: List[OrderItem] = Field(..., description="List of items in the order") + status: OrderStatus = Field(..., description="Current order status") + + def to_db_dict(self) -> dict: + """Convert to database format with snake_case.""" + return { + "order_id": self.orderId, + "customer_id": self.customerId, + "items": [item.to_db_dict() for item in self.items], + "status": self.status.value + } + + @classmethod + def from_db_dict(cls, data: dict) -> "Order": + """Create from database format with snake_case.""" + return cls( + orderId=data["order_id"], + customerId=data["customer_id"], + items=[OrderItem.from_db_dict(item) for item in data["items"]], + status=OrderStatus(data["status"]) + ) + + +class OrderCreateRequest(BaseModel): + orderId: Optional[int] = Field(None, description="Unique order identifier (auto-generated if not provided)") + customerId: int = Field(..., description="Customer ID placing the order") + items: List[OrderItemRequest] = Field(..., min_items=1, description="List of items to order") + + @validator('items') + def validate_unique_products(cls, v): + product_ids = [item.productId for item in v] + if len(product_ids) != len(set(product_ids)): + raise ValueError('Duplicate products in order items') + return v + + +class OrderStatusUpdateRequest(BaseModel): + status: OrderStatus = Field(..., description="New order status") + + +class OrderResponse(BaseModel): + orderId: int + customerId: int + items: List[OrderItem] + status: OrderStatus + + @staticmethod + def from_order(order: Order) -> "OrderResponse": + return OrderResponse( + orderId=order.orderId, + customerId=order.customerId, + items=order.items, + status=order.status + ) + + +class OrderListResponse(BaseModel): + items: List[OrderResponse] + total: int \ No newline at end of file diff --git a/tutorial/dapr/services/orders/k8s/dapr/statestore.yaml b/tutorial/dapr/services/orders/k8s/dapr/statestore.yaml new file mode 100644 index 0000000..b6bbc36 --- /dev/null +++ b/tutorial/dapr/services/orders/k8s/dapr/statestore.yaml @@ -0,0 +1,16 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: orders-store +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=orders-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=ordersdb sslmode=disable" + - name: tableName + value: "orders" + - name: keyPrefix + value: "none" + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/orders/k8s/deployment.yaml b/tutorial/dapr/services/orders/k8s/deployment.yaml new file mode 100644 index 0000000..68137bc --- /dev/null +++ b/tutorial/dapr/services/orders/k8s/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: orders + labels: + app: orders +spec: + replicas: 1 + selector: + matchLabels: + app: orders + template: + metadata: + labels: + app: orders + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "orders" + dapr.io/app-port: "8000" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: orders + image: ghcr.io/drasi-project/learning/dapr/orders-service:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: DAPR_STORE_NAME + value: "orders-store" + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: orders + labels: + app: orders +spec: + selector: + app: orders + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: orders-stripprefix +spec: + stripPrefix: + prefixes: + - /orders-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: orders + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-orders-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /orders-service + pathType: Prefix + backend: + service: + name: orders + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/orders/k8s/postgres/postgres.yaml b/tutorial/dapr/services/orders/k8s/postgres/postgres.yaml new file mode 100644 index 0000000..779f136 --- /dev/null +++ b/tutorial/dapr/services/orders/k8s/postgres/postgres.yaml @@ -0,0 +1,107 @@ +apiVersion: v1 +kind: Secret +metadata: + name: orders-db-credentials + labels: + app: orders-db +type: Opaque +stringData: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: orders-db + labels: + app: orders-db +spec: + serviceName: orders-db + replicas: 1 + selector: + matchLabels: + app: orders-db + template: + metadata: + labels: + app: orders-db + spec: + containers: + - name: postgres + image: postgres:14 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: orders-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: orders-db-credentials + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: ordersdb + args: + - -c + - wal_level=logical + - -c + - max_replication_slots=5 + - -c + - max_wal_senders=10 + volumeMounts: + - name: orders-db-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + limits: + cpu: "1" + memory: "1Gi" + requests: + cpu: "0.5" + memory: "512Mi" + volumeClaimTemplates: + - metadata: + name: orders-db-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: orders-db + labels: + app: orders-db +spec: + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres + selector: + app: orders-db + type: ClusterIP \ No newline at end of file diff --git a/tutorial/dapr/services/orders/requirements.txt b/tutorial/dapr/services/orders/requirements.txt new file mode 100644 index 0000000..9cccb30 --- /dev/null +++ b/tutorial/dapr/services/orders/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.24.0 +pydantic==2.10.5 +dapr==1.15.0 +python-json-logger==2.0.7 \ No newline at end of file diff --git a/tutorial/dapr/services/orders/setup/load-initial-data.sh b/tutorial/dapr/services/orders/setup/load-initial-data.sh new file mode 100755 index 0000000..4983d09 --- /dev/null +++ b/tutorial/dapr/services/orders/setup/load-initial-data.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +# Script to load initial order data via the Orders Service API +# Usage: ./load-initial-data.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/orders-service}" + +echo "Loading initial order data to: $BASE_URL" +echo "========================================" + +# Wait for service to be ready +if ! wait_for_service "$BASE_URL"; then + print_error "Service is not ready. Exiting." + exit 1 +fi + +echo "" +# Initial order data - PENDING orders +# Using customer IDs 1-10 and product IDs 1001-1010 +# Order IDs 3001-3023 (23 orders total) +declare -a pending_orders=( + # Customer 1 orders - tech enthusiast + '{"orderId": 3001, "customerId": 1, "items": [{"productId": 1001, "quantity": 1}, {"productId": 1002, "quantity": 1}]}' + '{"orderId": 3003, "customerId": 1, "items": [{"productId": 1009, "quantity": 2}]}' + + # Customer 2 orders - fitness focused + '{"orderId": 3005, "customerId": 2, "items": [{"productId": 1005, "quantity": 2}, {"productId": 1006, "quantity": 1}]}' + + # Customer 3 orders - home office setup + '{"orderId": 3007, "customerId": 3, "items": [{"productId": 1009, "quantity": 1}, {"productId": 1010, "quantity": 2}]}' + + # Customer 4 orders - mixed purchases + '{"orderId": 3004, "customerId": 2, "items": [{"productId": 1003, "quantity": 1}]}' + '{"orderId": 3008, "customerId": 4, "items": [{"productId": 1001, "quantity": 2}]}' + + # Customer 5 orders - accessories buyer + '{"orderId": 3010, "customerId": 5, "items": [{"productId": 1006, "quantity": 3}]}' + '{"orderId": 3011, "customerId": 5, "items": [{"productId": 1008, "quantity": 2}, {"productId": 1010, "quantity": 1}]}' + + # Customer 6 orders - premium buyer + '{"orderId": 3013, "customerId": 6, "items": [{"productId": 1001, "quantity": 1}]}' + + # Customer 7 orders - bulk buyer + '{"orderId": 3014, "customerId": 7, "items": [{"productId": 1005, "quantity": 5}]}' + + # Customer 8 orders - variety shopper + '{"orderId": 3016, "customerId": 8, "items": [{"productId": 1002, "quantity": 1}]}' + '{"orderId": 3017, "customerId": 8, "items": [{"productId": 1003, "quantity": 1}, {"productId": 1009, "quantity": 1}]}' + '{"orderId": 3018, "customerId": 8, "items": [{"productId": 1008, "quantity": 1}, {"productId": 1005, "quantity": 2}]}' + + # Customer 9 orders - gadget lover + '{"orderId": 3020, "customerId": 9, "items": [{"productId": 1010, "quantity": 4}]}' + + # Customer 10 orders - business purchases + '{"orderId": 3022, "customerId": 10, "items": [{"productId": 1009, "quantity": 3}, {"productId": 1008, "quantity": 3}]}' + '{"orderId": 3023, "customerId": 10, "items": [{"productId": 1002, "quantity": 5}]}' +) + +# Orders with specific statuses +declare -a status_orders=( + # PAID orders + '{"orderId": 3002, "customerId": 1, "items": [{"productId": 1007, "quantity": 1}], "status": "PAID"}' + '{"orderId": 3012, "customerId": 6, "items": [{"productId": 1007, "quantity": 1}, {"productId": 1004, "quantity": 1}], "status": "PAID"}' + + # SHIPPED orders + '{"orderId": 3006, "customerId": 3, "items": [{"productId": 1004, "quantity": 1}, {"productId": 1008, "quantity": 1}], "status": "SHIPPED"}' + '{"orderId": 3019, "customerId": 9, "items": [{"productId": 1001, "quantity": 1}, {"productId": 1003, "quantity": 1}, {"productId": 1009, "quantity": 1}], "status": "SHIPPED"}' + + # DELIVERED orders + '{"orderId": 3009, "customerId": 4, "items": [{"productId": 1002, "quantity": 1}, {"productId": 1003, "quantity": 1}, {"productId": 1005, "quantity": 1}], "status": "DELIVERED"}' + '{"orderId": 3021, "customerId": 10, "items": [{"productId": 1004, "quantity": 2}, {"productId": 1007, "quantity": 2}], "status": "DELIVERED"}' + + # CANCELLED order + '{"orderId": 3015, "customerId": 7, "items": [{"productId": 1006, "quantity": 10}, {"productId": 1010, "quantity": 3}], "status": "CANCELLED"}' +) + +# Track results +success_count=0 +fail_count=0 + +# Create PENDING orders +echo "Creating PENDING orders..." +for order in "${pending_orders[@]}"; do + order_id=$(echo "$order" | grep -o '"orderId": [0-9]*' | cut -d' ' -f2) + customer_id=$(echo "$order" | grep -o '"customerId": [0-9]*' | cut -d' ' -f2) + item_count=$(echo "$order" | grep -o '"productId"' | wc -l | tr -d ' ') + + echo -n "Creating order ID $order_id for Customer $customer_id with $item_count item(s)... " + + response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$order") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ]; then + print_success "SUCCESS (PENDING)" + ((success_count++)) + else + print_error "FAILED (HTTP $http_code)" + echo " Error: $body" + ((fail_count++)) + fi +done + +# Create orders with specific statuses +echo "" +echo "Creating orders with specific statuses..." +for order in "${status_orders[@]}"; do + order_id=$(echo "$order" | grep -o '"orderId": [0-9]*' | cut -d' ' -f2) + customer_id=$(echo "$order" | grep -o '"customerId": [0-9]*' | cut -d' ' -f2) + item_count=$(echo "$order" | grep -o '"productId"' | wc -l | tr -d ' ') + status=$(echo "$order" | grep -o '"status": "[^"]*"' | cut -d'"' -f4) + + # First create the order (API requires orders to be created as PENDING) + order_without_status=$(echo "$order" | sed 's/, "status": "[^"]*"//') + echo -n "Creating order ID $order_id for Customer $customer_id with $item_count item(s)... " + + response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$order_without_status") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ]; then + print_success "SUCCESS" + ((success_count++)) + + # Now update the status + echo -n " Updating order $order_id to status: $status... " + status_json="{\"status\": \"$status\"}" + + status_response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$order_id/status" "$status_json") + status_http_code=$(echo "$status_response" | tail -1) + + if [ "$status_http_code" = "200" ]; then + print_success "SUCCESS" + else + print_error "FAILED" + fi + else + print_error "FAILED (HTTP $http_code)" + echo " Error: $body" + ((fail_count++)) + fi +done + +echo "" +echo "========================================" +echo "Summary: $success_count succeeded, $fail_count failed" +echo "" +echo "Order IDs: 3001-3023" +echo " +Total: 23 orders" + +if [ $fail_count -eq 0 ]; then + print_success "All orders loaded successfully!" + exit 0 +else + print_error "Some orders failed to load" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/orders/setup/test-apis.sh b/tutorial/dapr/services/orders/setup/test-apis.sh new file mode 100755 index 0000000..05e970d --- /dev/null +++ b/tutorial/dapr/services/orders/setup/test-apis.sh @@ -0,0 +1,264 @@ +#!/bin/bash + +# Script to perform sanity check on Orders Service APIs +# Usage: ./test-apis.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/orders-service}" + +echo "Orders Service API Sanity Check" +echo "=================================" +echo "Base URL: $BASE_URL" +echo "" + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to check HTTP status code with counter +check_status() { + local expected="$1" + local actual="$2" + local test_name="$3" + local body="$4" + + if check_http_status "$expected" "$actual" "$test_name" "$body"; then + ((TESTS_PASSED++)) + return 0 + else + ((TESTS_FAILED++)) + return 1 + fi +} + +echo "1. Testing Health Check" +echo "-----------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/health" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "200" "$http_code" "Health check endpoint" "$body" + +echo "" +echo "2. Creating Test Order" +echo "----------------------" +TEST_ORDER='{ + "customerId": 9999, + "items": [ + {"productId": 1001, "quantity": 2}, + {"productId": 1002, "quantity": 1} + ] +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$TEST_ORDER") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "201" "$http_code" "Create order" "$body"; then + ORDER_ID=$(echo "$body" | grep -o '"orderId":[0-9]*' | cut -d':' -f2) + echo " Created order with ID: $ORDER_ID" + + # Verify initial status is PENDING + if echo "$body" | grep -q '"status":"PENDING"'; then + print_test_result "Verify initial status" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify initial status" "false" "Initial status should be PENDING" + ((TESTS_FAILED++)) + fi +else + echo "Failed to create order. Stopping tests." + exit 1 +fi + +echo "" +echo "3. Getting Created Order" +echo "------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/orders/$ORDER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Get order" "$body"; then + # Verify the order data + if echo "$body" | grep -q '"customerId":9999' && echo "$body" | grep -q '"productId":1001' && echo "$body" | grep -q '"quantity":2'; then + print_test_result "Verify order data" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify order data" "false" "Order data doesn't match expected values" + ((TESTS_FAILED++)) + fi + +fi + +echo "" +echo "4. Updating Order Status" +echo "------------------------" +# Test valid status transitions +STATUSES=("PAID" "PROCESSING" "SHIPPED" "DELIVERED") + +for status in "${STATUSES[@]}"; do + STATUS_UPDATE="{\"status\": \"$status\"}" + + echo -n " Updating to $status... " + response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$ORDER_ID/status" "$STATUS_UPDATE") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "200" ]; then + if echo "$body" | grep -q "\"status\":\"$status\""; then + print_success "SUCCESS" + ((TESTS_PASSED++)) + else + print_error "FAILED - Status not updated" + ((TESTS_FAILED++)) + fi + else + print_error "FAILED - HTTP $http_code" + ((TESTS_FAILED++)) + fi +done + +echo "" +echo "5. Testing Invalid Status Transition" +echo "------------------------------------" +# Try to update a delivered order (should fail) +INVALID_UPDATE='{"status": "PENDING"}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$ORDER_ID/status" "$INVALID_UPDATE") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "400" "$http_code" "Invalid status transition" "$body" + +echo "" +echo "6. Testing Invalid Status Value" +echo "--------------------------------" +INVALID_STATUS='{"status": "INVALID_STATUS"}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$ORDER_ID/status" "$INVALID_STATUS") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Invalid status value" "$body" + +echo "" +echo "7. Creating Order with Duplicate Products" +echo "-----------------------------------------" +DUPLICATE_ITEMS='{ + "customerId": 9998, + "items": [ + {"productId": 1001, "quantity": 1}, + {"productId": 1001, "quantity": 2} + ] +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$DUPLICATE_ITEMS") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Duplicate products error" "$body" + +echo "" +echo "8. Creating Order with Empty Items" +echo "-----------------------------------" +EMPTY_ITEMS='{"customerId": 9997, "items": []}' + +response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$EMPTY_ITEMS") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Empty items error" "$body" + +echo "" +echo "9. Creating Order with Invalid Quantity" +echo "----------------------------------------" +INVALID_QUANTITY='{ + "customerId": 9996, + "items": [{"productId": 1001, "quantity": 0}] +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$INVALID_QUANTITY") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Invalid quantity error" "$body" + +echo "" +echo "10. Testing 404 for Non-existent Order" +echo "---------------------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/orders/999999999" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get non-existent order" "$body" + +echo "" +echo "11. Creating Cancelled Order" +echo "----------------------------" +CANCEL_ORDER='{ + "customerId": 9995, + "items": [{"productId": 1005, "quantity": 1}] +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/orders" "$CANCEL_ORDER") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if [ "$http_code" = "201" ]; then + CANCEL_ORDER_ID=$(echo "$body" | grep -o '"orderId":[0-9]*' | cut -d':' -f2) + echo " Created order to cancel with ID: $CANCEL_ORDER_ID" + ((TESTS_PASSED++)) + + # Cancel the order + CANCEL_UPDATE='{"status": "CANCELLED"}' + response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$CANCEL_ORDER_ID/status" "$CANCEL_UPDATE") + http_code=$(echo "$response" | tail -1) + + if check_status "200" "$http_code" "Cancel order" "$body"; then + # Try to update cancelled order (should fail) + AFTER_CANCEL='{"status": "PAID"}' + response=$(make_request_with_retry "PUT" "$BASE_URL/orders/$CANCEL_ORDER_ID/status" "$AFTER_CANCEL") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + check_status "400" "$http_code" "Update cancelled order" "$body" + fi +else + ((TESTS_FAILED++)) +fi + +echo "" +echo "12. Cleaning Up Test Orders" +echo "---------------------------" +# Delete the main test order +response=$(make_request_with_retry "DELETE" "$BASE_URL/orders/$ORDER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "204" "$http_code" "Delete main test order" "$body" + +# Delete the cancelled order if it exists +if [ ! -z "$CANCEL_ORDER_ID" ]; then + response=$(make_request_with_retry "DELETE" "$BASE_URL/orders/$CANCEL_ORDER_ID" "") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + check_status "204" "$http_code" "Delete cancelled order" "$body" +fi + +echo "" +echo "13. Verifying Cleanup" +echo "---------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/orders/$ORDER_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Verify main order deleted" "$body" + +echo "" +echo "=================================" +echo "Test Summary" +echo "=================================" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "\n${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}Some tests failed!${NC}" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/products/Dockerfile b/tutorial/dapr/services/products/Dockerfile new file mode 100644 index 0000000..c8cd6c7 --- /dev/null +++ b/tutorial/dapr/services/products/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.13-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY code/ . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/tutorial/dapr/services/products/code/dapr_client.py b/tutorial/dapr/services/products/code/dapr_client.py new file mode 100644 index 0000000..ea86830 --- /dev/null +++ b/tutorial/dapr/services/products/code/dapr_client.py @@ -0,0 +1,119 @@ +import json +import logging +import os +import base64 +from typing import Optional, Any, List, Dict +from dapr.clients import DaprClient + +logger = logging.getLogger(__name__) + + +class DaprStateStore: + def __init__(self, store_name: Optional[str] = None): + self.store_name = store_name or os.getenv("DAPR_STORE_NAME", "products-store") + self.client = DaprClient() + logger.info(f"Initialized Dapr state store client for store: {self.store_name}") + + async def get_item(self, key: str) -> Optional[dict]: + """Get an item from the state store.""" + try: + response = self.client.get_state( + store_name=self.store_name, + key=key + ) + + if response.data: + data = json.loads(response.data) + logger.debug(f"Retrieved item with key '{key}': {data}") + return data + else: + logger.debug(f"No item found with key '{key}'") + return None + + except Exception as e: + logger.error(f"Error getting item with key '{key}': {str(e)}") + raise + + async def save_item(self, key: str, data: dict) -> None: + """Save an item to the state store.""" + try: + self.client.save_state( + store_name=self.store_name, + key=key, + value=json.dumps(data) + ) + logger.debug(f"Saved item with key '{key}': {data}") + + except Exception as e: + logger.error(f"Error saving item with key '{key}': {str(e)}") + raise + + async def delete_item(self, key: str) -> None: + """Delete an item from the state store.""" + try: + self.client.delete_state( + store_name=self.store_name, + key=key + ) + logger.debug(f"Deleted item with key '{key}'") + + except Exception as e: + logger.error(f"Error deleting item with key '{key}': {str(e)}") + raise + + async def query_items(self, query: Dict[str, Any]) -> tuple[List[Dict[str, Any]], Optional[str]]: + """ + Query items from the state store using Dapr state query API. + + Args: + query: Query dictionary with filter, sort, and page options + + Returns: + Tuple of (results list, pagination token) + """ + try: + query_json = json.dumps(query) + logger.debug(f"Executing state query with: {query_json}") + response = self.client.query_state( + store_name=self.store_name, + query=query_json + ) + + results = [] + for item in response.results: + try: + # The value might already be a string (JSON), not bytes + if hasattr(item.value, 'decode'): + # It's bytes, decode it + value_str = item.value.decode('UTF-8') + else: + # It's already a string + value_str = item.value + + # Parse the JSON string + value = json.loads(value_str) + + # If the value is a string, it might be base64 encoded JSON + if isinstance(value, str): + try: + # Try base64 decoding + decoded_bytes = base64.b64decode(value) + decoded_str = decoded_bytes.decode('utf-8') + value = json.loads(decoded_str) + except Exception: + # Keep the original string value if base64 decode fails + pass + + results.append({ + 'key': item.key, + 'value': value + }) + except Exception as e: + logger.error(f"Failed to parse item with key {item.key}: {e}") + + logger.debug(f"Query completed - returned {len(results)} items") + return results, response.token + + except Exception as e: + logger.error(f"Error querying state store: {str(e)}") + raise \ No newline at end of file diff --git a/tutorial/dapr/services/products/code/main.py b/tutorial/dapr/services/products/code/main.py new file mode 100644 index 0000000..784de02 --- /dev/null +++ b/tutorial/dapr/services/products/code/main.py @@ -0,0 +1,318 @@ +import logging +import os +import time +from contextlib import asynccontextmanager +from typing import Optional + +from fastapi import FastAPI, HTTPException, Depends, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from models import ProductItem, ProductCreateRequest, StockUpdateRequest, ProductResponse, ProductListResponse +from dapr_client import DaprStateStore + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global state store instance +state_store = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global state_store + state_store = DaprStateStore() + logger.info("Product service started") + yield + # Shutdown + logger.info("Product service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Product Service", + description="Manages product stock levels with Dapr state store", + version="1.0.0", + lifespan=lifespan, + root_path="/products-service" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_state_store() -> DaprStateStore: + """Dependency to get the state store instance.""" + if state_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="State store not initialized" + ) + return state_store + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "products"} + + +@app.get("/products", response_model=ProductListResponse) +async def list_products( + store: DaprStateStore = Depends(get_state_store) +): + """Get all products.""" + start_time = time.time() + + try: + # Simple query with empty filter to get all items + query = { + "filter": {} + } + + # Execute the query + results, _ = await store.query_items(query) + + # Convert results to ProductResponse objects + items = [] + for result in results: + try: + product_item = ProductItem.from_db_dict(result['value']) + items.append(ProductResponse.from_product_item(product_item)) + except Exception as e: + logger.warning(f"Failed to parse item with key {result['key']}: {str(e)}") + continue + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved {len(items)} products in {elapsed:.2f}ms") + + return ProductListResponse( + items=items, + total=len(items) + ) + + except Exception as e: + logger.error(f"Error listing products: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list products: {str(e)}" + ) + + +@app.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED) +async def create_or_update_product( + request: ProductCreateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Add or update a product's details.""" + start_time = time.time() + + try: + # Check if item already exists + existing = await store.get_item(str(request.productId)) + + # Create product item + product_item = ProductItem( + productId=request.productId, + productName=request.productName, + productDescription=request.productDescription, + stockOnHand=request.stockOnHand, + lowStockThreshold=request.lowStockThreshold + ) + + # Save to state store + await store.save_item(str(request.productId), product_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"{'Updated' if existing else 'Created'} product {request.productId} in {elapsed:.2f}ms") + + # Return appropriate status code via response + response = ProductResponse.from_product_item(product_item) + return JSONResponse( + content=response.model_dump(), + status_code=status.HTTP_200_OK if existing else status.HTTP_201_CREATED + ) + + except Exception as e: + logger.error(f"Error creating/updating product: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to save product: {str(e)}" + ) + + +@app.get("/products/{product_id}", response_model=ProductResponse) +async def get_product( + product_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Get product details.""" + start_time = time.time() + + try: + data = await store.get_item(str(product_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product {product_id} not found" + ) + + product_item = ProductItem.from_db_dict(data) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved product {product_id} in {elapsed:.2f}ms") + + return ProductResponse.from_product_item(product_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving product: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve product: {str(e)}" + ) + + +@app.put("/products/{product_id}/decrement", response_model=ProductResponse) +async def decrement_stock( + product_id: int, + request: StockUpdateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Decrement stock for a product.""" + start_time = time.time() + + try: + # Get current inventory + data = await store.get_item(str(product_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product {product_id} not found" + ) + + product_item = ProductItem.from_db_dict(data) + + # Check if we have enough stock + if product_item.stockOnHand < request.quantity: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Insufficient stock. Available: {product_item.stockOnHand}, Requested: {request.quantity}" + ) + + # Update stock + product_item.stockOnHand -= request.quantity + + # Save back to state store + await store.save_item(str(product_id), product_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Decremented stock for product {product_id} by {request.quantity} in {elapsed:.2f}ms") + + return ProductResponse.from_product_item(product_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error decrementing stock: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to decrement stock: {str(e)}" + ) + + +@app.put("/products/{product_id}/increment", response_model=ProductResponse) +async def increment_stock( + product_id: int, + request: StockUpdateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Increment stock for a product.""" + start_time = time.time() + + try: + # Get current inventory + data = await store.get_item(str(product_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product {product_id} not found" + ) + + product_item = ProductItem.from_db_dict(data) + + # Update stock + product_item.stockOnHand += request.quantity + + # Save back to state store + await store.save_item(str(product_id), product_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Incremented stock for product {product_id} by {request.quantity} in {elapsed:.2f}ms") + + return ProductResponse.from_product_item(product_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error incrementing stock: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to increment stock: {str(e)}" + ) + + +@app.delete("/products/{product_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_product( + product_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Delete a product.""" + start_time = time.time() + + try: + # Check if product exists + data = await store.get_item(str(product_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Product {product_id} not found" + ) + + # Delete from state store + await store.delete_item(str(product_id)) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Deleted product {product_id} in {elapsed:.2f}ms") + + return None + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting product: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete product: {str(e)}" + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/products/code/models.py b/tutorial/dapr/services/products/code/models.py new file mode 100644 index 0000000..5018145 --- /dev/null +++ b/tutorial/dapr/services/products/code/models.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel, Field +from typing import Optional, List + + +class ProductItem(BaseModel): + productId: int = Field(..., description="Unique product identifier") + productName: str = Field(..., description="Name of the product") + productDescription: str = Field(..., description="Description of the product") + stockOnHand: int = Field(..., ge=0, description="Current stock level") + lowStockThreshold: int = Field(..., ge=0, description="Threshold for low stock alerts") + + def to_db_dict(self) -> dict: + """Convert to database format with snake_case.""" + return { + "product_id": self.productId, + "product_name": self.productName, + "product_description": self.productDescription, + "stock_on_hand": self.stockOnHand, + "low_stock_threshold": self.lowStockThreshold + } + + @classmethod + def from_db_dict(cls, data: dict) -> "ProductItem": + """Create from database format with snake_case.""" + return cls( + productId=data["product_id"], + productName=data["product_name"], + productDescription=data["product_description"], + stockOnHand=data["stock_on_hand"], + lowStockThreshold=data["low_stock_threshold"] + ) + + +class ProductCreateRequest(BaseModel): + productId: int = Field(..., description="Unique product identifier") + productName: str = Field(..., description="Name of the product") + productDescription: str = Field(..., description="Description of the product") + stockOnHand: int = Field(..., ge=0, description="Current stock level") + lowStockThreshold: int = Field(..., ge=0, description="Threshold for low stock alerts") + + +class StockUpdateRequest(BaseModel): + quantity: int = Field(..., gt=0, description="Quantity to increment or decrement") + + +class ProductResponse(BaseModel): + productId: int + productName: str + productDescription: str + stockOnHand: int + lowStockThreshold: int + isLowStock: bool = Field(..., description="Whether stock is below threshold") + + @staticmethod + def from_product_item(item: ProductItem) -> "ProductResponse": + return ProductResponse( + productId=item.productId, + productName=item.productName, + productDescription=item.productDescription, + stockOnHand=item.stockOnHand, + lowStockThreshold=item.lowStockThreshold, + isLowStock=item.stockOnHand <= item.lowStockThreshold + ) + + +class ProductListResponse(BaseModel): + items: List[ProductResponse] + total: int \ No newline at end of file diff --git a/tutorial/dapr/services/products/k8s/dapr/statestore.yaml b/tutorial/dapr/services/products/k8s/dapr/statestore.yaml new file mode 100644 index 0000000..6442486 --- /dev/null +++ b/tutorial/dapr/services/products/k8s/dapr/statestore.yaml @@ -0,0 +1,16 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: products-store +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=products-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=productsdb sslmode=disable" + - name: tableName + value: "products" + - name: keyPrefix + value: "none" + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/products/k8s/deployment.yaml b/tutorial/dapr/services/products/k8s/deployment.yaml new file mode 100644 index 0000000..6b1394f --- /dev/null +++ b/tutorial/dapr/services/products/k8s/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: products + labels: + app: products +spec: + replicas: 1 + selector: + matchLabels: + app: products + template: + metadata: + labels: + app: products + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "products" + dapr.io/app-port: "8000" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: products + image: ghcr.io/drasi-project/learning/dapr/products-service:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: DAPR_STORE_NAME + value: "products-store" + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: products + labels: + app: products +spec: + selector: + app: products + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: products-stripprefix +spec: + stripPrefix: + prefixes: + - /products-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: products + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-products-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /products-service + pathType: Prefix + backend: + service: + name: products + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/products/k8s/postgres/postgres.yaml b/tutorial/dapr/services/products/k8s/postgres/postgres.yaml new file mode 100644 index 0000000..7c71129 --- /dev/null +++ b/tutorial/dapr/services/products/k8s/postgres/postgres.yaml @@ -0,0 +1,107 @@ +apiVersion: v1 +kind: Secret +metadata: + name: products-db-credentials + labels: + app: products-db +type: Opaque +stringData: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: products-db + labels: + app: products-db +spec: + serviceName: products-db + replicas: 1 + selector: + matchLabels: + app: products-db + template: + metadata: + labels: + app: products-db + spec: + containers: + - name: postgres + image: postgres:14 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: products-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: products-db-credentials + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: productsdb + args: + - -c + - wal_level=logical + - -c + - max_replication_slots=5 + - -c + - max_wal_senders=10 + volumeMounts: + - name: products-db-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + limits: + cpu: "1" + memory: "1Gi" + requests: + cpu: "0.5" + memory: "512Mi" + volumeClaimTemplates: + - metadata: + name: products-db-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: products-db + labels: + app: products-db +spec: + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres + selector: + app: products-db + type: ClusterIP \ No newline at end of file diff --git a/tutorial/dapr/services/products/requirements.txt b/tutorial/dapr/services/products/requirements.txt new file mode 100644 index 0000000..9cccb30 --- /dev/null +++ b/tutorial/dapr/services/products/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.24.0 +pydantic==2.10.5 +dapr==1.15.0 +python-json-logger==2.0.7 \ No newline at end of file diff --git a/tutorial/dapr/services/products/setup/load-initial-data.sh b/tutorial/dapr/services/products/setup/load-initial-data.sh new file mode 100755 index 0000000..9c16844 --- /dev/null +++ b/tutorial/dapr/services/products/setup/load-initial-data.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +# Script to load initial products data via the products Service API +# Usage: ./load-initial-data.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/products-service}" + +echo "Loading initial products data to: $BASE_URL" +echo "========================================" + +# Wait for service to be ready +if ! wait_for_service "$BASE_URL"; then + print_error "Service is not ready. Exiting." + exit 1 +fi + +echo "" +# Initial products data with specific IDs +declare -a products=( + '{"productId": 1001, "productName": "Smartphone XS", "productDescription": "Latest flagship smartphone with 5G connectivity and AI camera", "stockOnHand": 15, "lowStockThreshold": 5}' + '{"productId": 1002, "productName": "Wireless Headphones Pro", "productDescription": "Noise-cancelling bluetooth headphones with 30hr battery", "stockOnHand": 8, "lowStockThreshold": 10}' + '{"productId": 1003, "productName": "Smart Watch Ultra", "productDescription": "Fitness tracker with heart rate monitor and GPS", "stockOnHand": 12, "lowStockThreshold": 5}' + '{"productId": 1004, "productName": "Tablet Pro 12.9\"", "productDescription": "High-performance tablet with stylus support", "stockOnHand": 2, "lowStockThreshold": 3}' + '{"productId": 1005, "productName": "Bluetooth Speaker Max", "productDescription": "Waterproof portable speaker with 360° sound", "stockOnHand": 20, "lowStockThreshold": 8}' + '{"productId": 1006, "productName": "Power Bank 20000mAh", "productDescription": "Fast charging power bank with multiple ports", "stockOnHand": 0, "lowStockThreshold": 5}' + '{"productId": 1007, "productName": "Gaming Laptop RTX", "productDescription": "High-end gaming laptop with RTX 4080 graphics", "stockOnHand": 4, "lowStockThreshold": 2}' + '{"productId": 1008, "productName": "Mechanical Keyboard RGB", "productDescription": "Gaming keyboard with customizable RGB lighting", "stockOnHand": 25, "lowStockThreshold": 10}' + '{"productId": 1009, "productName": "4K Webcam Pro", "productDescription": "Professional webcam with AI-powered autofocus", "stockOnHand": 18, "lowStockThreshold": 7}' + '{"productId": 1010, "productName": "USB-C Hub 10-in-1", "productDescription": "Multi-port hub with HDMI, ethernet, and card readers", "stockOnHand": 30, "lowStockThreshold": 15}' +) + +# Track results +success_count=0 +fail_count=0 + +# Create each products +for products in "${products[@]}"; do + id=$(echo "$products" | grep -o '"productId": [0-9]*' | cut -d' ' -f2) + name=$(echo "$products" | grep -o '"productName": "[^"]*"' | cut -d'"' -f4) + echo -n "Creating products ID $id: $name... " + + response=$(make_request_with_retry "POST" "$BASE_URL/products" "$products") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + print_success "SUCCESS" + ((success_count++)) + else + print_error "FAILED (HTTP $http_code)" + echo " Error: $body" + ((fail_count++)) + fi +done + +echo "========================================" +echo "Summary: $success_count succeeded, $fail_count failed" + +if [ $fail_count -eq 0 ]; then + print_success "All products loaded successfully!" + exit 0 +else + print_error "Some products failed to load" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/products/setup/test-apis.sh b/tutorial/dapr/services/products/setup/test-apis.sh new file mode 100755 index 0000000..f63e5b7 --- /dev/null +++ b/tutorial/dapr/services/products/setup/test-apis.sh @@ -0,0 +1,233 @@ +#!/bin/bash + +# Script to perform sanity check on Product Service APIs +# Usage: ./test-apis.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/products-service}" + +echo "Product Service API Sanity Check" +echo "=================================" +echo "Base URL: $BASE_URL" +echo "" + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to check HTTP status code with counter +check_status() { + local expected="$1" + local actual="$2" + local test_name="$3" + local body="$4" + + if check_http_status "$expected" "$actual" "$test_name" "$body"; then + ((TESTS_PASSED++)) + return 0 + else + ((TESTS_FAILED++)) + return 1 + fi +} + +echo "1. Testing Health Check" +echo "-----------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/health" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "200" "$http_code" "Health check endpoint" "$body" + +echo "" +echo "2. Creating Test Product" +echo "------------------------" +TEST_PRODUCT='{ + "productId": 9999, + "productName": "Test Product", + "productDescription": "This is a test product", + "stockOnHand": 100, + "lowStockThreshold": 20 +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/products" "$TEST_PRODUCT") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + PRODUCT_ID=9999 + if [ "$http_code" = "201" ]; then + print_test_result "Create product" "true" + echo " Created product with ID: $PRODUCT_ID" + else + print_test_result "Create/Update product" "true" + echo " Created/Updated product with ID: $PRODUCT_ID" + fi + ((TESTS_PASSED++)) +else + print_test_result "Create product" "false" "Expected HTTP 201 or 200, got $http_code. Response: $body" + ((TESTS_FAILED++)) + echo "Failed to create product. Stopping tests." + exit 1 +fi + +echo "" +echo "3. Getting Created Product" +echo "--------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/products/$PRODUCT_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Get product" "$body"; then + # Verify the product data + if echo "$body" | grep -q "Test Product" && echo "$body" | grep -q '"stockOnHand":100'; then + print_test_result "Verify product data" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify product data" "false" "Product data doesn't match expected values" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "4. Updating Product (Creating with existing ID)" +echo "-----------------------------------------------" +UPDATE_PRODUCT='{ + "productId": 9999, + "productName": "Updated Test Product", + "productDescription": "This is an updated test product", + "stockOnHand": 150, + "lowStockThreshold": 30 +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/products" "$UPDATE_PRODUCT") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Update product (via POST)" "$body"; then + # Verify the update + if echo "$body" | grep -q "Updated Test Product" && echo "$body" | grep -q '"stockOnHand":150'; then + print_test_result "Verify updated data" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify updated data" "false" "Updated data doesn't match expected values" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "5. Testing Stock Decrement" +echo "--------------------------" +STOCK_UPDATE='{"quantity": 10}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/products/$PRODUCT_ID/decrement" "$STOCK_UPDATE") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Decrement stock" "$body"; then + # Verify the stock was decremented + if echo "$body" | grep -q '"stockOnHand":140'; then + print_test_result "Verify stock decrement" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify stock decrement" "false" "Stock not decremented correctly" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "6. Testing Stock Increment" +echo "--------------------------" +response=$(make_request_with_retry "PUT" "$BASE_URL/products/$PRODUCT_ID/increment" "$STOCK_UPDATE") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Increment stock" "$body"; then + # Verify the stock was incremented + if echo "$body" | grep -q '"stockOnHand":150'; then + print_test_result "Verify stock increment" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify stock increment" "false" "Stock not incremented correctly" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "7. Testing Low Stock Flag" +echo "-------------------------" +# Update product to have low stock +LOW_STOCK_PRODUCT='{ + "productId": 9999, + "productName": "Low Stock Test Product", + "productDescription": "Testing low stock flag", + "stockOnHand": 25, + "lowStockThreshold": 30 +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/products" "$LOW_STOCK_PRODUCT") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Update to low stock" "$body"; then + # Verify low stock flag + if echo "$body" | grep -q '"isLowStock":true'; then + print_test_result "Verify low stock flag" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify low stock flag" "false" "Low stock flag not set correctly" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "8. Testing Insufficient Stock Decrement" +echo "---------------------------------------" +LARGE_DECREMENT='{"quantity": 100}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/products/$PRODUCT_ID/decrement" "$LARGE_DECREMENT") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "400" "$http_code" "Insufficient stock error" "$body" + +echo "" +echo "9. Testing 404 for Non-existent Product" +echo "----------------------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/products/999999999" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get non-existent product" "$body" + +echo "" +echo "10. Cleaning Up Test Product" +echo "----------------------------" +response=$(make_request_with_retry "DELETE" "$BASE_URL/products/$PRODUCT_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "204" "$http_code" "Delete test product" "$body" + +echo "" +echo "11. Verifying Cleanup" +echo "---------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/products/$PRODUCT_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Verify product deleted" "$body" + +echo "" +echo "=================================" +echo "Test Summary" +echo "=================================" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "\n${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}Some tests failed!${NC}" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/Dockerfile b/tutorial/dapr/services/reviews/Dockerfile new file mode 100644 index 0000000..c8cd6c7 --- /dev/null +++ b/tutorial/dapr/services/reviews/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM python:3.13-slim AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Runtime stage +FROM python:3.13-slim + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY code/ . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/code/dapr_client.py b/tutorial/dapr/services/reviews/code/dapr_client.py new file mode 100644 index 0000000..055b89f --- /dev/null +++ b/tutorial/dapr/services/reviews/code/dapr_client.py @@ -0,0 +1,119 @@ +import json +import logging +import os +import base64 +from typing import Optional, Any, List, Dict +from dapr.clients import DaprClient + +logger = logging.getLogger(__name__) + + +class DaprStateStore: + def __init__(self, store_name: Optional[str] = None): + self.store_name = store_name or os.getenv("DAPR_STORE_NAME", "reviews-store") + self.client = DaprClient() + logger.info(f"Initialized Dapr state store client for store: {self.store_name}") + + async def get_item(self, key: str) -> Optional[dict]: + """Get an item from the state store.""" + try: + response = self.client.get_state( + store_name=self.store_name, + key=key + ) + + if response.data: + data = json.loads(response.data) + logger.debug(f"Retrieved item with key '{key}': {data}") + return data + else: + logger.debug(f"No item found with key '{key}'") + return None + + except Exception as e: + logger.error(f"Error getting item with key '{key}': {str(e)}") + raise + + async def save_item(self, key: str, data: dict) -> None: + """Save an item to the state store.""" + try: + self.client.save_state( + store_name=self.store_name, + key=key, + value=json.dumps(data) + ) + logger.debug(f"Saved item with key '{key}': {data}") + + except Exception as e: + logger.error(f"Error saving item with key '{key}': {str(e)}") + raise + + async def delete_item(self, key: str) -> None: + """Delete an item from the state store.""" + try: + self.client.delete_state( + store_name=self.store_name, + key=key + ) + logger.debug(f"Deleted item with key '{key}'") + + except Exception as e: + logger.error(f"Error deleting item with key '{key}': {str(e)}") + raise + + async def query_items(self, query: Dict[str, Any]) -> tuple[List[Dict[str, Any]], Optional[str]]: + """ + Query items from the state store using Dapr state query API. + + Args: + query: Query dictionary with filter, sort, and page options + + Returns: + Tuple of (results list, pagination token) + """ + try: + query_json = json.dumps(query) + logger.debug(f"Executing state query with: {query_json}") + response = self.client.query_state( + store_name=self.store_name, + query=query_json + ) + + results = [] + for item in response.results: + try: + # The value might already be a string (JSON), not bytes + if hasattr(item.value, 'decode'): + # It's bytes, decode it + value_str = item.value.decode('UTF-8') + else: + # It's already a string + value_str = item.value + + # Parse the JSON string + value = json.loads(value_str) + + # If the value is a string, it might be base64 encoded JSON + if isinstance(value, str): + try: + # Try base64 decoding + decoded_bytes = base64.b64decode(value) + decoded_str = decoded_bytes.decode('utf-8') + value = json.loads(decoded_str) + except Exception: + # Keep the original string value if base64 decode fails + pass + + results.append({ + 'key': item.key, + 'value': value + }) + except Exception as e: + logger.error(f"Failed to parse item with key {item.key}: {e}") + + logger.debug(f"Query completed - returned {len(results)} items") + return results, response.token + + except Exception as e: + logger.error(f"Error querying state store: {str(e)}") + raise \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/code/main.py b/tutorial/dapr/services/reviews/code/main.py new file mode 100644 index 0000000..3977111 --- /dev/null +++ b/tutorial/dapr/services/reviews/code/main.py @@ -0,0 +1,284 @@ +import logging +import os +import time +from contextlib import asynccontextmanager +from typing import Optional +import random + +from fastapi import FastAPI, HTTPException, Depends, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from models import ReviewItem, ReviewCreateRequest, ReviewUpdateRequest, ReviewResponse, ReviewListResponse +from dapr_client import DaprStateStore + +# Configure logging +logging.basicConfig( + level=os.getenv("LOG_LEVEL", "INFO"), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Global state store instance +state_store = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + global state_store + state_store = DaprStateStore() + logger.info("Reviews service started") + yield + # Shutdown + logger.info("Reviews service shutting down") + + +# Create FastAPI app +app = FastAPI( + title="Reviews Service", + description="Manages customer reviews for products with Dapr state store", + version="1.0.0", + lifespan=lifespan, + root_path="/reviews-service" +) + +# Add CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def get_state_store() -> DaprStateStore: + """Dependency to get the state store instance.""" + if state_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="State store not initialized" + ) + return state_store + + +@app.get("/health") +async def health_check(): + """Health check endpoint.""" + return {"status": "healthy", "service": "reviews"} + + +@app.get("/reviews", response_model=ReviewListResponse) +async def list_reviews( + store: DaprStateStore = Depends(get_state_store) +): + """Get all reviews.""" + start_time = time.time() + + try: + # Simple query with empty filter to get all items + query = { + "filter": {} + } + + # Execute the query + results, _ = await store.query_items(query) + + # Convert results to ReviewResponse objects + items = [] + for result in results: + try: + review_item = ReviewItem.from_db_dict(result['value']) + items.append(ReviewResponse.from_review_item(review_item)) + except Exception as e: + logger.warning(f"Failed to parse item with key {result['key']}: {str(e)}") + continue + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved {len(items)} reviews in {elapsed:.2f}ms") + + return ReviewListResponse(items=items, total=len(items)) + + except Exception as e: + elapsed = (time.time() - start_time) * 1000 + logger.error(f"Failed to list reviews after {elapsed:.2f}ms: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list reviews: {str(e)}" + ) + + +@app.post("/reviews", response_model=ReviewResponse, status_code=status.HTTP_201_CREATED) +async def create_review( + request: ReviewCreateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Submit a new review.""" + start_time = time.time() + + try: + # Use provided review ID or generate a unique one + if request.reviewId: + review_id = request.reviewId + # Check if ID already exists + existing = await store.get_item(str(review_id)) + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Review with ID {review_id} already exists" + ) + else: + # Generate a unique review ID + review_id = random.randint(4001, 999999) + # Check if ID already exists + existing = await store.get_item(str(review_id)) + while existing: + review_id = random.randint(4001, 999999) + existing = await store.get_item(str(review_id)) + + # Create review item + review_item = ReviewItem( + reviewId=review_id, + productId=request.productId, + customerId=request.customerId, + rating=request.rating, + reviewText=request.reviewText if request.reviewText is not None else "" + ) + + # Save to state store + await store.save_item(str(review_id), review_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Created review {review_id} for product {request.productId} by customer {request.customerId} in {elapsed:.2f}ms") + + return ReviewResponse.from_review_item(review_item) + + except Exception as e: + logger.error(f"Error creating review: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create review: {str(e)}" + ) + + +@app.get("/reviews/{review_id}", response_model=ReviewResponse) +async def get_review( + review_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Get a specific review by its ID.""" + start_time = time.time() + + try: + data = await store.get_item(str(review_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Review {review_id} not found" + ) + + review_item = ReviewItem.from_db_dict(data) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Retrieved review {review_id} in {elapsed:.2f}ms") + + return ReviewResponse.from_review_item(review_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error retrieving review: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to retrieve review: {str(e)}" + ) + + +@app.put("/reviews/{review_id}", response_model=ReviewResponse) +async def update_review( + review_id: int, + request: ReviewUpdateRequest, + store: DaprStateStore = Depends(get_state_store) +): + """Update a review.""" + start_time = time.time() + + try: + # Get current review + data = await store.get_item(str(review_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Review {review_id} not found" + ) + + review_item = ReviewItem.from_db_dict(data) + + # Update fields if provided + if request.rating is not None: + review_item.rating = request.rating + # For reviewText, we need to check if the field was provided in the request + # The validator converts empty strings to None, so we update if the field exists + if hasattr(request, 'reviewText') and 'reviewText' in request.__fields_set__: + review_item.reviewText = request.reviewText + + # Save back to state store + await store.save_item(str(review_id), review_item.to_db_dict()) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Updated review {review_id} in {elapsed:.2f}ms") + + return ReviewResponse.from_review_item(review_item) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating review: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update review: {str(e)}" + ) + + +@app.delete("/reviews/{review_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_review( + review_id: int, + store: DaprStateStore = Depends(get_state_store) +): + """Delete a review.""" + start_time = time.time() + + try: + # Check if review exists + data = await store.get_item(str(review_id)) + + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Review {review_id} not found" + ) + + # Delete from state store + await store.delete_item(str(review_id)) + + elapsed = (time.time() - start_time) * 1000 + logger.info(f"Deleted review {review_id} in {elapsed:.2f}ms") + + return None + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error deleting review: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to delete review: {str(e)}" + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/code/models.py b/tutorial/dapr/services/reviews/code/models.py new file mode 100644 index 0000000..f36b9c2 --- /dev/null +++ b/tutorial/dapr/services/reviews/code/models.py @@ -0,0 +1,91 @@ +from pydantic import BaseModel, Field, validator +from typing import Optional, List + + +class ReviewItem(BaseModel): + reviewId: int = Field(..., description="Unique review identifier") + productId: int = Field(..., description="Product being reviewed") + customerId: int = Field(..., description="Customer who wrote the review") + rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5") + reviewText: Optional[str] = Field("", description="Optional review text") + + @validator('reviewText') + def validate_review_text(cls, v): + if v is None: + return "" + if len(v.strip()) == 0: + return "" + return v + + def to_db_dict(self) -> dict: + """Convert to database format with snake_case.""" + return { + "review_id": self.reviewId, + "product_id": self.productId, + "customer_id": self.customerId, + "rating": self.rating, + "review_text": self.reviewText if self.reviewText is not None else "" + } + + @classmethod + def from_db_dict(cls, data: dict) -> "ReviewItem": + """Create from database format with snake_case.""" + return cls( + reviewId=data["review_id"], + productId=data["product_id"], + customerId=data["customer_id"], + rating=data["rating"], + reviewText=data.get("review_text", "") or "" # Handle null values + ) + + +class ReviewCreateRequest(BaseModel): + reviewId: Optional[int] = Field(None, description="Unique review identifier (auto-generated if not provided)") + productId: int = Field(..., description="Product to review") + customerId: int = Field(..., description="Customer submitting the review") + rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5") + reviewText: Optional[str] = Field(None, description="Optional review text") + + @validator('reviewText') + def validate_review_text(cls, v): + if v is None: + return "" + if len(v.strip()) == 0: + return "" + return v + + +class ReviewUpdateRequest(BaseModel): + rating: Optional[int] = Field(None, ge=1, le=5, description="Updated rating") + reviewText: Optional[str] = Field(None, description="Updated review text") + + @validator('reviewText') + def validate_review_text(cls, v): + if v is None: + return "" + if len(v.strip()) == 0: + return "" + return v + + +class ReviewResponse(BaseModel): + reviewId: int + productId: int + customerId: int + rating: int + reviewText: str # Always non-null + + @staticmethod + def from_review_item(item: ReviewItem) -> "ReviewResponse": + return ReviewResponse( + reviewId=item.reviewId, + productId=item.productId, + customerId=item.customerId, + rating=item.rating, + reviewText=item.reviewText if item.reviewText is not None else "" + ) + + +class ReviewListResponse(BaseModel): + items: List[ReviewResponse] + total: int \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/k8s/dapr/statestore.yaml b/tutorial/dapr/services/reviews/k8s/dapr/statestore.yaml new file mode 100644 index 0000000..8222930 --- /dev/null +++ b/tutorial/dapr/services/reviews/k8s/dapr/statestore.yaml @@ -0,0 +1,16 @@ +apiVersion: dapr.io/v1alpha1 +kind: Component +metadata: + name: reviews-store +spec: + type: state.postgresql + version: v1 + metadata: + - name: connectionString + value: "host=reviews-db.default.svc.cluster.local port=5432 user=postgres password=postgres dbname=reviewsdb sslmode=disable" + - name: tableName + value: "reviews" + - name: keyPrefix + value: "none" + - name: actorStateStore + value: "false" \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/k8s/deployment.yaml b/tutorial/dapr/services/reviews/k8s/deployment.yaml new file mode 100644 index 0000000..ac4e572 --- /dev/null +++ b/tutorial/dapr/services/reviews/k8s/deployment.yaml @@ -0,0 +1,96 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: reviews + labels: + app: reviews +spec: + replicas: 1 + selector: + matchLabels: + app: reviews + template: + metadata: + labels: + app: reviews + annotations: + dapr.io/enabled: "true" + dapr.io/app-id: "reviews" + dapr.io/app-port: "8000" + dapr.io/enable-api-logging: "true" + dapr.io/log-level: "info" + spec: + containers: + - name: reviews + image: ghcr.io/drasi-project/learning/dapr/reviews-service:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + env: + - name: DAPR_STORE_NAME + value: "reviews-store" + - name: LOG_LEVEL + value: "INFO" + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 15 + periodSeconds: 20 + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "100m" + memory: "128Mi" +--- +apiVersion: v1 +kind: Service +metadata: + name: reviews + labels: + app: reviews +spec: + selector: + app: reviews + ports: + - name: http + port: 80 + targetPort: 8000 + protocol: TCP + type: ClusterIP +--- +apiVersion: traefik.containo.us/v1alpha1 +kind: Middleware +metadata: + name: reviews-stripprefix +spec: + stripPrefix: + prefixes: + - /reviews-service +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: reviews + annotations: + traefik.ingress.kubernetes.io/router.middlewares: default-reviews-stripprefix@kubernetescrd +spec: + rules: + - http: + paths: + - path: /reviews-service + pathType: Prefix + backend: + service: + name: reviews + port: + number: 80 \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/k8s/postgres/postgres.yaml b/tutorial/dapr/services/reviews/k8s/postgres/postgres.yaml new file mode 100644 index 0000000..7f5f705 --- /dev/null +++ b/tutorial/dapr/services/reviews/k8s/postgres/postgres.yaml @@ -0,0 +1,107 @@ +apiVersion: v1 +kind: Secret +metadata: + name: reviews-db-credentials + labels: + app: reviews-db +type: Opaque +stringData: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: reviews-db + labels: + app: reviews-db +spec: + serviceName: reviews-db + replicas: 1 + selector: + matchLabels: + app: reviews-db + template: + metadata: + labels: + app: reviews-db + spec: + containers: + - name: postgres + image: postgres:14 + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_USER + valueFrom: + secretKeyRef: + name: reviews-db-credentials + key: POSTGRES_USER + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: reviews-db-credentials + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: reviewsdb + args: + - -c + - wal_level=logical + - -c + - max_replication_slots=5 + - -c + - max_wal_senders=10 + volumeMounts: + - name: reviews-db-data + mountPath: /var/lib/postgresql/data + readinessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 15 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + exec: + command: + - /bin/sh + - -c + - pg_isready -U postgres -h localhost + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + resources: + limits: + cpu: "1" + memory: "1Gi" + requests: + cpu: "0.5" + memory: "512Mi" + volumeClaimTemplates: + - metadata: + name: reviews-db-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: reviews-db + labels: + app: reviews-db +spec: + ports: + - port: 5432 + targetPort: 5432 + protocol: TCP + name: postgres + selector: + app: reviews-db + type: ClusterIP \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/requirements.txt b/tutorial/dapr/services/reviews/requirements.txt new file mode 100644 index 0000000..9cccb30 --- /dev/null +++ b/tutorial/dapr/services/reviews/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.5 +uvicorn[standard]==0.24.0 +pydantic==2.10.5 +dapr==1.15.0 +python-json-logger==2.0.7 \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/setup/load-initial-data.sh b/tutorial/dapr/services/reviews/setup/load-initial-data.sh new file mode 100755 index 0000000..bfe1205 --- /dev/null +++ b/tutorial/dapr/services/reviews/setup/load-initial-data.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +# Script to load initial review data via the Reviews Service API +# Usage: ./load-initial-data.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/reviews-service}" + +echo "Loading initial review data to: $BASE_URL" +echo "========================================" + +# Wait for service to be ready +if ! wait_for_service "$BASE_URL"; then + print_error "Service is not ready. Exiting." + exit 1 +fi + +echo "" +# Initial review data - 40 reviews with natural distribution and explicit IDs +# Customer IDs 1-10 from customer service +# Product IDs 1001-1010 from product service +# Review IDs 4001-4040 +declare -a reviews=( + # Product 1001 (Smartphone XS) - 8 reviews (most popular) + '{"reviewId": 4001, "productId": 1001, "customerId": 1, "rating": 5, "reviewText": "Excellent smartphone! The camera quality is outstanding and the battery life is impressive."}' + '{"reviewId": 4002, "productId": 1001, "customerId": 4, "rating": 4, "reviewText": "Great phone overall, but a bit pricey. Performance is smooth and design is sleek."}' + '{"reviewId": 4003, "productId": 1001, "customerId": 8, "rating": 5, "reviewText": "Best phone I have ever owned. Fast, reliable, and the display is gorgeous!"}' + '{"reviewId": 4004, "productId": 1001, "customerId": 2, "rating": 4, "reviewText": "Good value for money. The 5G connectivity is super fast."}' + '{"reviewId": 4005, "productId": 1001, "customerId": 5, "rating": 5, "reviewText": "Amazing camera features! Night mode is incredible."}' + '{"reviewId": 4006, "productId": 1001, "customerId": 9, "rating": 3, "reviewText": "Good phone but battery drains quickly with heavy use."}' + '{"reviewId": 4007, "productId": 1001, "customerId": 3, "rating": 5, "reviewText": ""}' + '{"reviewId": 4008, "productId": 1001, "customerId": 6, "rating": 4, "reviewText": "Solid performance, great for multitasking."}' + + # Product 1002 (Wireless Headphones Pro) - 6 reviews (popular accessory) + '{"reviewId": 4009, "productId": 1002, "customerId": 2, "rating": 4, "reviewText": "Good sound quality and comfortable to wear. Noise cancellation works well."}' + '{"reviewId": 4010, "productId": 1002, "customerId": 3, "rating": 3, "reviewText": "Decent headphones but the battery life could be better."}' + '{"reviewId": 4011, "productId": 1002, "customerId": 7, "rating": 5, "reviewText": ""}' + '{"reviewId": 4012, "productId": 1002, "customerId": 1, "rating": 4, "reviewText": "Great for calls and music. Bluetooth connection is stable."}' + '{"reviewId": 4013, "productId": 1002, "customerId": 8, "rating": 5, "reviewText": "Best noise cancellation I have experienced. Worth every penny!"}' + '{"reviewId": 4014, "productId": 1002, "customerId": 10, "rating": 4, "reviewText": "Comfortable even after long use. Sound quality exceeds expectations."}' + + # Product 1003 (Smart Watch Ultra) - 5 reviews (fitness enthusiasts) + '{"reviewId": 4015, "productId": 1003, "customerId": 1, "rating": 4, "reviewText": "Great fitness tracker with accurate heart rate monitoring."}' + '{"reviewId": 4016, "productId": 1003, "customerId": 6, "rating": 5, "reviewText": "Love this watch! Tracks everything I need and looks stylish too."}' + '{"reviewId": 4017, "productId": 1003, "customerId": 3, "rating": 4, "reviewText": "Battery lasts 2 days with heavy use. GPS is accurate."}' + '{"reviewId": 4018, "productId": 1003, "customerId": 8, "rating": 5, "reviewText": ""}' + '{"reviewId": 4019, "productId": 1003, "customerId": 2, "rating": 5, "reviewText": "Best smartwatch for fitness enthusiasts. Waterproof works great!"}' + + # Product 1004 (Tablet Pro 12.9") - 4 reviews (premium product, fewer buyers) + '{"reviewId": 4020, "productId": 1004, "customerId": 4, "rating": 5, "reviewText": "Amazing tablet for creative work. The stylus is very responsive."}' + '{"reviewId": 4021, "productId": 1004, "customerId": 10, "rating": 4, "reviewText": ""}' + '{"reviewId": 4022, "productId": 1004, "customerId": 1, "rating": 5, "reviewText": "Perfect for digital art. Screen quality is outstanding."}' + '{"reviewId": 4023, "productId": 1004, "customerId": 7, "rating": 5, "reviewText": "Replaced my laptop for most tasks. Very powerful and portable."}' + + # Product 1005 (Bluetooth Speaker Max) - 3 reviews (moderate popularity) + '{"reviewId": 4024, "productId": 1005, "customerId": 5, "rating": 3, "reviewText": "Good speaker but not as loud as advertised."}' + '{"reviewId": 4025, "productId": 1005, "customerId": 7, "rating": 4, "reviewText": "Waterproof feature works great! Perfect for pool parties."}' + '{"reviewId": 4026, "productId": 1005, "customerId": 9, "rating": 4, "reviewText": ""}' + + # Product 1006 (Power Bank 20000mAh) - 2 reviews (least popular) + '{"reviewId": 4027, "productId": 1006, "customerId": 2, "rating": 2, "reviewText": "Charges slowly and gets warm. Expected better quality."}' + '{"reviewId": 4028, "productId": 1006, "customerId": 6, "rating": 3, "reviewText": "Works as expected but nothing special. Decent backup option."}' + + # Product 1007 (Gaming Laptop RTX) - 5 reviews (expensive but gamers love it) + '{"reviewId": 4029, "productId": 1007, "customerId": 1, "rating": 5, "reviewText": "Incredible gaming performance! Runs all modern games at max settings."}' + '{"reviewId": 4030, "productId": 1007, "customerId": 4, "rating": 5, "reviewText": "Worth every penny. Build quality is excellent."}' + '{"reviewId": 4031, "productId": 1007, "customerId": 10, "rating": 4, "reviewText": ""}' + '{"reviewId": 4032, "productId": 1007, "customerId": 7, "rating": 5, "reviewText": "Beast of a machine! RTX 4080 handles everything I throw at it."}' + '{"reviewId": 4033, "productId": 1007, "customerId": 3, "rating": 5, "reviewText": "Perfect for both gaming and content creation. No regrets!"}' + + # Product 1008 (Mechanical Keyboard RGB) - 3 reviews (niche product) + '{"reviewId": 4034, "productId": 1008, "customerId": 6, "rating": 4, "reviewText": "Nice mechanical feel and RGB lighting. Cherry switches are great."}' + '{"reviewId": 4035, "productId": 1008, "customerId": 7, "rating": 5, "reviewText": "Best keyboard for the price! Typing experience is fantastic."}' + '{"reviewId": 4036, "productId": 1008, "customerId": 9, "rating": 3, "reviewText": ""}' + + # Product 1009 (4K Webcam Pro) - 4 reviews (popular for remote work) + '{"reviewId": 4037, "productId": 1009, "customerId": 2, "rating": 5, "reviewText": "Crystal clear video quality. Autofocus works like magic."}' + '{"reviewId": 4038, "productId": 1009, "customerId": 4, "rating": 4, "reviewText": "Great webcam for remote work. Much better than laptop camera."}' + '{"reviewId": 4039, "productId": 1009, "customerId": 8, "rating": 5, "reviewText": ""}' + '{"reviewId": 4040, "productId": 1009, "customerId": 1, "rating": 5, "reviewText": "AI-powered features are impressive. Worth the investment."}' + + # Product 1010 (USB-C Hub 10-in-1) - 2 reviews (practical accessory) + '{"reviewId": 4041, "productId": 1010, "customerId": 5, "rating": 4, "reviewText": "All ports work as expected. Good build quality."}' + '{"reviewId": 4042, "productId": 1010, "customerId": 3, "rating": 5, "reviewText": "Essential for my MacBook. All ports work perfectly, no issues."}' +) + +# Track results +success_count=0 +fail_count=0 + +# Create each review +for review in "${reviews[@]}"; do + review_id=$(echo "$review" | grep -o '"reviewId": [0-9]*' | cut -d' ' -f2) + product_id=$(echo "$review" | grep -o '"productId": [0-9]*' | cut -d' ' -f2) + customer_id=$(echo "$review" | grep -o '"customerId": [0-9]*' | cut -d' ' -f2) + rating=$(echo "$review" | grep -o '"rating": [0-9]' | cut -d' ' -f2) + + echo -n "Creating review ID $review_id for Product $product_id by Customer $customer_id (Rating: $rating)... " + + response=$(make_request_with_retry "POST" "$BASE_URL/reviews" "$review") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" = "201" ]; then + print_success "SUCCESS" + ((success_count++)) + else + print_error "FAILED (HTTP $http_code)" + echo " Error: $body" + ((fail_count++)) + fi +done + +echo "" +echo "========================================" +echo "Summary: $success_count succeeded, $fail_count failed" + +# Print distribution summary +echo "" +echo "Review Distribution by Product:" +echo "------------------------------" +for product_id in 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010; do + count=$(echo "${reviews[@]}" | grep -o "\"productId\": $product_id" | wc -l | tr -d ' ') + echo "Product $product_id: $count reviews" +done + +echo "" +echo "Review IDs: 4001-4042" + +if [ $fail_count -eq 0 ]; then + print_success "All reviews loaded successfully!" + exit 0 +else + print_error "Some reviews failed to load" + exit 1 +fi \ No newline at end of file diff --git a/tutorial/dapr/services/reviews/setup/test-apis.sh b/tutorial/dapr/services/reviews/setup/test-apis.sh new file mode 100755 index 0000000..2eaf0e6 --- /dev/null +++ b/tutorial/dapr/services/reviews/setup/test-apis.sh @@ -0,0 +1,226 @@ +#!/bin/bash + +# Script to perform sanity check on Reviews Service APIs +# Usage: ./test-apis.sh [base_url] + +# Source common utilities +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../../common/setup/common-utils.sh" + +# Use provided base URL or default to localhost +BASE_URL="${1:-http://localhost/reviews-service}" + +echo "Reviews Service API Sanity Check" +echo "=================================" +echo "Base URL: $BASE_URL" +echo "" + +# Track test results +TESTS_PASSED=0 +TESTS_FAILED=0 + +# Function to check HTTP status code with counter +check_status() { + local expected="$1" + local actual="$2" + local test_name="$3" + local body="$4" + + if check_http_status "$expected" "$actual" "$test_name" "$body"; then + ((TESTS_PASSED++)) + return 0 + else + ((TESTS_FAILED++)) + return 1 + fi +} + +echo "1. Testing Health Check" +echo "-----------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/health" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "200" "$http_code" "Health check endpoint" "$body" + +echo "" +echo "2. Creating Test Review" +echo "-----------------------" +TEST_REVIEW='{ + "productId": 1001, + "customerId": 999, + "rating": 5, + "reviewText": "This is a test review" +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/reviews" "$TEST_REVIEW") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "201" "$http_code" "Create review" "$body"; then + REVIEW_ID=$(echo "$body" | grep -o '"reviewId":[0-9]*' | cut -d':' -f2) + echo " Created review with ID: $REVIEW_ID" +else + echo "Failed to create review. Stopping tests." + exit 1 +fi + +echo "" +echo "3. Getting Created Review" +echo "-------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/reviews/$REVIEW_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Get review" "$body"; then + # Verify the review data + if echo "$body" | grep -q '"productId":1001' && echo "$body" | grep -q '"customerId":999' && echo "$body" | grep -q '"rating":5'; then + print_test_result "Verify review data" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify review data" "false" "Review data doesn't match expected values" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "4. Updating Review" +echo "------------------" +UPDATE_REVIEW='{ + "rating": 4, + "reviewText": "Updated test review - actually it was just okay" +}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/reviews/$REVIEW_ID" "$UPDATE_REVIEW") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Update review" "$body"; then + # Verify the update + if echo "$body" | grep -q '"rating":4' && echo "$body" | grep -q "Updated test review"; then + print_test_result "Verify updated data" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify updated data" "false" "Updated data doesn't match expected values" + ((TESTS_FAILED++)) + fi + + # Verify productId and customerId didn't change + if echo "$body" | grep -q '"productId":1001' && echo "$body" | grep -q '"customerId":999'; then + print_test_result "Verify immutable fields" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify immutable fields" "false" "Product ID or Customer ID changed unexpectedly" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "5. Testing Empty Review Text" +echo "----------------------------" +EMPTY_TEXT_REVIEW='{ + "rating": 3, + "reviewText": "" +}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/reviews/$REVIEW_ID" "$EMPTY_TEXT_REVIEW") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "200" "$http_code" "Update with empty text" "$body"; then + # Verify empty text becomes empty string + if echo "$body" | grep -q '"reviewText":""'; then + print_test_result "Verify empty text handling" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify empty text handling" "false" "Empty text not converted to empty string" + echo " DEBUG: Response body: $body" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "6. Testing Invalid Rating" +echo "-------------------------" +INVALID_RATING='{"rating": 6}' + +response=$(make_request_with_retry "PUT" "$BASE_URL/reviews/$REVIEW_ID" "$INVALID_RATING") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Invalid rating (>5)" "$body" + +INVALID_RATING='{"rating": 0}' +response=$(make_request_with_retry "PUT" "$BASE_URL/reviews/$REVIEW_ID" "$INVALID_RATING") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "422" "$http_code" "Invalid rating (<1)" "$body" + +echo "" +echo "7. Testing 404 for Non-existent Review" +echo "---------------------------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/reviews/999999999" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get non-existent review" "$body" + +echo "" +echo "8. Creating Review Without Text" +echo "--------------------------------" +NO_TEXT_REVIEW='{ + "productId": 1002, + "customerId": 998, + "rating": 4 +}' + +response=$(make_request_with_retry "POST" "$BASE_URL/reviews" "$NO_TEXT_REVIEW") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') + +if check_status "201" "$http_code" "Create review without text" "$body"; then + NO_TEXT_REVIEW_ID=$(echo "$body" | grep -o '"reviewId":[0-9]*' | cut -d':' -f2) + if echo "$body" | grep -q '"reviewText":""'; then + print_test_result "Verify empty text in response" "true" + ((TESTS_PASSED++)) + else + print_test_result "Verify empty text in response" "false" "Review text should be empty string" + echo " DEBUG: Response body: $body" + ((TESTS_FAILED++)) + fi +fi + +echo "" +echo "9. Deleting Test Reviews" +echo "------------------------" +response=$(make_request_with_retry "DELETE" "$BASE_URL/reviews/$REVIEW_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "204" "$http_code" "Delete first review" "$body" + +if [ ! -z "$NO_TEXT_REVIEW_ID" ]; then + response=$(make_request_with_retry "DELETE" "$BASE_URL/reviews/$NO_TEXT_REVIEW_ID" "") + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | sed '$d') + check_status "204" "$http_code" "Delete second review" "$body" +fi + +echo "" +echo "10. Verifying Deletion" +echo "----------------------" +response=$(make_request_with_retry "GET" "$BASE_URL/reviews/$REVIEW_ID" "") +http_code=$(echo "$response" | tail -1) +body=$(echo "$response" | sed '$d') +check_status "404" "$http_code" "Get deleted review" "$body" + +echo "" +echo "=================================" +echo "Test Summary" +echo "=================================" +echo -e "Tests Passed: ${GREEN}$TESTS_PASSED${NC}" +echo -e "Tests Failed: ${RED}$TESTS_FAILED${NC}" + +if [ $TESTS_FAILED -eq 0 ]; then + echo -e "\n${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "\n${RED}Some tests failed!${NC}" + exit 1 +fi \ No newline at end of file