commit a467069c9c1c7a86c5b1a0c7c7bfe31e230cf2cd Author: vithobaa Date: Mon Jun 29 18:20:23 2026 +0530 feat: initial AutoPilot monorepo - master/ : Spring Boot cloud coordinator (Gap 1-4 features) - worker/ : Selenium execution engine - ui/ : React + Vite dashboard (CI/CD integration page) - docker-compose.yml : production deployment - docker-compose.local.yml : local dev (DB only) - .env.example : environment variable template - README.md : full deployment and local setup guide diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee4ad15 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# AutoPilot - Production Environment Variables +# Copy this file to .env and fill in the real values before deploying + +# ─── Database (PostgreSQL) ─────────────────────────────────────────────────── +DB_URL=jdbc:postgresql://postgres:5432/localagent_cloud +DB_USER=localagent +DB_PASS=changeme_strong_password_here + +# ─── Security ──────────────────────────────────────────────────────────────── +# Must be at least 256 bits (32+ characters). Generate one with: +# openssl rand -base64 64 +JWT_SECRET=replace_this_with_a_very_long_random_secret_string_minimum_256_bits + +# ─── AWS S3 (for screenshot storage) ───────────────────────────────────────── +AWS_REGION=ap-south-1 +AWS_ACCESS_KEY_ID=your_aws_access_key +AWS_SECRET_ACCESS_KEY=your_aws_secret_key +S3_BUCKET_NAME=autopilot-screenshots + +# ─── Worker (AutoPilot Engine) ─────────────────────────────────────────────── +# URL where the master backend is reachable from the worker container +LOCALAGENT_CLOUD_URL=http://master:9090 +LOCALAGENT_POLLING_ENABLED=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4519bae --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# AutoPilot — ignore secrets and build artifacts +.env +*.env + +# Java build artifacts +**/target/ +**/*.class +**/*.jar +!**/mvnw +!**/mvnw.cmd + +# Node / Frontend +**/node_modules/ +**/dist/ + +# IDE +.idea/ +.vscode/ +*.iml + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +*.log.* + +# Data / runtime +**/data/ +**/screenshots/ +**/DriverFiles/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..650d2f2 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# AutoPilot — Distributed Web Test Automation Platform + +AutoPilot is a full-stack, enterprise-grade distributed web browser automation platform. It consists of three components deployed together as a monorepo. + +``` +autopilot/ +├── master/ # Cloud Coordinator — Spring Boot REST API + Scheduler + DB +├── worker/ # Execution Engine — Selenium WebDriver agent (polls master) +├── ui/ # Dashboard — React + Vite frontend (served via Nginx) +├── docker-compose.yml # Production: full stack with PostgreSQL +├── docker-compose.local.yml # Local dev: DB only (run services manually) +└── .env.example # Template for environment variables +``` + +--- + +## 🚀 Production Deployment (AWS / Any Linux Server) + +### Prerequisites +- Docker & Docker Compose installed +- At least 2 CPU cores and 2GB RAM + +### 1. Clone the repository +```bash +git clone https://repo.qruize.com/VithobaaSrinivasakumar/Autopilot.git +cd Autopilot +``` + +### 2. Configure environment variables +```bash +cp .env.example .env +nano .env # Fill in DB_PASS, JWT_SECRET, AWS keys, etc. +``` + +### 3. Deploy the full stack +```bash +docker-compose up -d --build +``` + +This starts: +- **PostgreSQL** (database, internal only) +- **AutoPilot Master** on port `9090` +- **AutoPilot UI** on port `80` (Nginx) + +### 4. Verify deployment +```bash +docker-compose ps +curl http://localhost:9090/api/health +``` + +### 5. Updating to a new version +```bash +git pull origin main +docker-compose up -d --build +``` + +--- + +## 🏗️ Local Development (No Docker for Services) + +### 1. Start only the database +```bash +docker-compose -f docker-compose.local.yml up -d +``` + +### 2. Start the Master (Terminal 1) +```bash +cd master +./mvnw spring-boot:run +# Runs on http://localhost:9090 +``` + +### 3. Start the UI (Terminal 2) +```bash +cd ui +npm install +npm run dev +# Runs on http://localhost:5173 +``` + +### 4. Start the Worker (Terminal 3) +```bash +cd worker +./mvnw spring-boot:run +# Polls http://localhost:9090 for jobs +``` + +--- + +## 🔗 CI/CD Integration + +AutoPilot can be triggered directly from your DevOps pipeline using an API key. + +### Trigger a test suite +```bash +curl -X POST \ + -H "Authorization: Bearer ap_live_your_api_key" \ + -H "Content-Type: application/json" \ + -d '{"suiteName": "Smoke Tests", "browser": "chrome"}' \ + https://your-domain.com/api/v1/suites/trigger-by-name +``` + +### Poll for results +```bash +curl -H "Authorization: Bearer ap_live_your_api_key" \ + https://your-domain.com/api/v1/schedulers/{schedulerId}/execution-status +# exitCode: 0 = all passed, 1 = failures, 2 = still running +``` + +For full GitHub Actions, GitLab CI, and Jenkins snippets, see the **CI/CD Integration** page inside the dashboard. + +--- + +## 🌐 Environment Variables Reference + +| Variable | Description | Required | +|---|---|---| +| `DB_URL` | JDBC connection string to PostgreSQL | ✅ | +| `DB_USER` | Database username | ✅ | +| `DB_PASS` | Database password | ✅ | +| `JWT_SECRET` | Minimum 256-bit secret for signing tokens | ✅ | +| `AWS_REGION` | AWS region for S3 screenshot storage | Optional | +| `AWS_ACCESS_KEY_ID` | AWS access key | Optional | +| `AWS_SECRET_ACCESS_KEY` | AWS secret key | Optional | +| `S3_BUCKET_NAME` | S3 bucket for storing screenshots | Optional | +| `LOCALAGENT_CLOUD_URL` | URL the Worker uses to reach the Master | Worker only | + +--- + +## 📖 API Reference + +| Method | Endpoint | Description | +|---|---|---| +| `POST` | `/api/v1/suites/{id}/trigger` | Trigger suite by ID | +| `POST` | `/api/v1/suites/trigger-by-name` | Trigger suite by name | +| `GET` | `/api/v1/schedulers/{id}/execution-status` | Poll execution result | +| `GET` | `/api/v1/executions/{id}/status` | Get execution detail | diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..5bc8c7e --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,25 @@ +version: '3.8' + +# ─── LOCAL DEVELOPMENT OVERRIDE ─────────────────────────────────────────────── +# Run with: docker-compose -f docker-compose.yml -f docker-compose.local.yml up +# Or just: docker-compose -f docker-compose.local.yml up +# +# This starts ONLY the database. Master, Worker, and UI are run directly +# from your terminal for fast hot-reloading during development. + +services: + postgres: + image: postgres:15-alpine + container_name: autopilot-postgres-local + environment: + POSTGRES_DB: localagent_cloud + POSTGRES_USER: localagent + POSTGRES_PASSWORD: localagent + ports: + - "5432:5432" + volumes: + - postgres_local_data:/var/lib/postgresql/data + restart: unless-stopped + +volumes: + postgres_local_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ca13bd8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,79 @@ +version: '3.8' + +services: + + # ─── PostgreSQL Database ────────────────────────────────────────────────── + postgres: + image: postgres:15-alpine + container_name: autopilot-postgres + environment: + POSTGRES_DB: localagent_cloud + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d localagent_cloud"] + interval: 10s + timeout: 5s + retries: 5 + + # ─── AutoPilot Master (Cloud Coordinator Backend) ───────────────────────── + master: + build: + context: ./master + dockerfile: Dockerfile + image: autopilot/master:latest + container_name: autopilot-master + ports: + - "9090:9090" + environment: + - SPRING_PROFILES_ACTIVE=prod + - DB_URL=${DB_URL} + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - JWT_SECRET=${JWT_SECRET} + - AWS_REGION=${AWS_REGION} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + - S3_BUCKET_NAME=${S3_BUCKET_NAME} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + # ─── AutoPilot UI (React Frontend via Nginx) ─────────────────────────────── + ui: + build: + context: ./ui + dockerfile: Dockerfile + image: autopilot/ui:latest + container_name: autopilot-ui + ports: + - "80:80" + - "443:443" + depends_on: + - master + restart: unless-stopped + + # ─── AutoPilot Worker (Execution Engine) ────────────────────────────────── + # NOTE: The worker needs access to a real browser (Chrome/Firefox). + # For headless browser automation, run the worker on a separate machine + # or a server with Xvfb installed. Uncomment if running on a capable host. + # + # worker: + # build: + # context: ./worker + # dockerfile: Dockerfile + # image: autopilot/worker:latest + # container_name: autopilot-worker + # environment: + # - LOCALAGENT_CLOUD_URL=${LOCALAGENT_CLOUD_URL} + # - LOCALAGENT_POLLING_ENABLED=${LOCALAGENT_POLLING_ENABLED} + # depends_on: + # - master + # restart: unless-stopped + +volumes: + postgres_data: diff --git a/master/.gitignore b/master/.gitignore new file mode 100644 index 0000000..55eb3bc --- /dev/null +++ b/master/.gitignore @@ -0,0 +1,49 @@ +# Build outputs +/target/ +**/target/ + +# IDEs +.idea/ +.vscode/ +*.iml + +# Maven wrapper downloads +.mvn/wrapper/maven-wrapper.jar + +# Local runtime data +/data/ +**/data/ +localagent-cloud/data/ + +# Logs +*.log +*.log.* + +# OS files +.DS_Store +Thumbs.db + +# Temporary files +*.tmp +*.swp +*.swo + +# Java +*.class +*.jar + +# Test reports +**/surefire-reports/ + +# Screenshots and generated artifacts +screenshots/ +**/screenshots/ + +# Vendor / dependency folders if they are generated locally +vendor/ +vendor_slim/ + +# Local agent runtime files +localagent/driver_log.txt +localagent/*.log +localagent/**/*.log diff --git a/master/.mvn/wrapper/maven-wrapper.properties b/master/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5291372 --- /dev/null +++ b/master/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.15/apache-maven-3.9.15-bin.zip diff --git a/master/Dockerfile b/master/Dockerfile new file mode 100644 index 0000000..4c02567 --- /dev/null +++ b/master/Dockerfile @@ -0,0 +1,14 @@ +# Stage 1: Build Backend +FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +WORKDIR /app +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +# Stage 2: Run (minimal JRE image) +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY --from=build /app/target/autopilot-master-0.0.1-SNAPSHOT.jar app.jar +EXPOSE 9090 +ENTRYPOINT ["java", "-Djava.security.egd=file:/dev/./urandom", "-jar", "app.jar"] diff --git a/master/README.md b/master/README.md new file mode 100644 index 0000000..f968994 --- /dev/null +++ b/master/README.md @@ -0,0 +1,79 @@ +# AutoPilot Platform + +AutoPilot is a high-performance web browser automation platform designed for distributed execution orchestration. It consists of three core components: + +--- + +## 📂 Project Structure + +```text +localagent/ +├── autopilot-ui/ # React/Vite Frontend (Web Dashboard) +├── autopilot-master/ # Cloud Coordinator Broker service (Spring Boot) +├── autopilot-worker/ # AutoPropel LocalAgent application runner (Spring Boot) +├── service-packaging/ # Windows Service wrappers and registration scripts +├── docker-compose.yml # Local development database infrastructure +└── pom.xml # Workspace parent POM for the Java projects +``` + +--- + +## 🛠️ Prerequisites +- **Java**: JDK 21 or JDK 25 installed and configured on your `PATH`. +- **Node.js**: v18+ (for running the React UI). +- **Browsers**: Google Chrome and/or Mozilla Firefox installed in default system paths. +- **Docker**: For running the local PostgreSQL database. + +--- + +## 🏗️ Local Development Setup + +To run the entire distributed platform on your local machine, follow these steps in order: + +### 1. Start the Local Database +We use Docker to spin up a local PostgreSQL database for development, so you don't need to connect to AWS. +From the root of the project: +```bash +docker-compose up -d +``` +*(This starts PostgreSQL on port `5432` with credentials `user: localagent`, `password: localagent`)* + +### 2. Start the Master (Cloud Coordinator) +The Master broker manages the database, serves the API, and schedules jobs. +Open a new terminal at the root of the project: +```bash +.\mvnw.cmd clean spring-boot:run -pl autopilot-master -Dspring-boot.run.profiles=default -DJWT_SECRET=supersecretjwtkeythatismorethan256bits +``` +*(The Master will start on port `9090`)* + +### 3. Start the UI (React Frontend) +The frontend dashboard allows you to visually interact with the platform. +Open a new terminal at the root of the project: +```bash +cd autopilot-ui +npm install +npm run dev +``` +*(The UI will start on port `5173` and proxy API requests to the Master on `9090`)* + +### 4. Start the Worker (Execution Engine) +The Worker actually drives the browser and runs the tests. It will automatically connect to your local Master to fetch jobs. +Open a new terminal at the root of the project: +```bash +.\mvnw.cmd clean spring-boot:run -pl autopilot-worker/localagent-java +``` +*(The Worker will start on port `8080` and begin polling `http://localhost:9090` for jobs)* + +--- + +## ☁️ Cloud / Production Deployment +When deploying to AWS or another cloud provider: +1. Deploy `autopilot-master` to an EC2 instance or ECS container and point it to a managed PostgreSQL RDS database using `DB_URL`, `DB_USER`, and `DB_PASS`. +2. Build and deploy `autopilot-ui` (e.g. via S3 + CloudFront or an Nginx container). +3. Distribute the `autopilot-worker` to the testing nodes. Set the `LOCALAGENT_CLOUD_URL` environment variable on the workers to point to your public Master API. + +Example for Worker: +```bash +set LOCALAGENT_CLOUD_URL=https://api.yourdomain.com +set LOCALAGENT_AGENT_ID=agent_windows_01 +``` diff --git a/master/docker-compose.yml b/master/docker-compose.yml new file mode 100644 index 0000000..1e84442 --- /dev/null +++ b/master/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: Dockerfile + image: autopilot/backend:latest + container_name: autopilot-backend + ports: + - "9090:9090" + restart: unless-stopped + environment: + - SPRING_PROFILES_ACTIVE=prod + # If you have a local postgres DB running, uncomment and configure: + # - SPRING_DATASOURCE_URL=jdbc:postgresql://host.docker.internal:5432/autopilot + # - SPRING_DATASOURCE_USERNAME=postgres + # - SPRING_DATASOURCE_PASSWORD=postgres + + diff --git a/master/fix.js b/master/fix.js new file mode 100644 index 0000000..fd3067a --- /dev/null +++ b/master/fix.js @@ -0,0 +1,11 @@ +const fs = require('fs'); +let content = fs.readFileSync('frontend/src/App.jsx', 'utf8'); + +// Replace fetch('/api/...) but not /api/auth +content = content.replace(/\bfetch\((['"`])\/api\/(?!auth)/g, 'api($1/api/'); + +// Replace fetch(url +content = content.replace(/\bfetch\(url/g, 'api(url'); + +fs.writeFileSync('frontend/src/App.jsx', content); +console.log('App.jsx updated'); diff --git a/master/mvnw b/master/mvnw new file mode 100644 index 0000000..bd8896b --- /dev/null +++ b/master/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/master/mvnw.cmd b/master/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/master/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/master/pom.xml b/master/pom.xml new file mode 100644 index 0000000..4d13e09 --- /dev/null +++ b/master/pom.xml @@ -0,0 +1,94 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.0 + + + com.autopilot + autopilot-master + 0.0.1-SNAPSHOT + autopilot-master + Cloud Coordinator for AutoPilot Local Agent + + 21 + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + + jakarta.persistence + jakarta.persistence-api + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + software.amazon.awssdk + s3 + 2.25.11 + + + + org.springframework.boot + spring-boot-starter-security + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/master/src/main/java/com/autopilot/localagent_cloud/LocalagentCloudApplication.java b/master/src/main/java/com/autopilot/localagent_cloud/LocalagentCloudApplication.java new file mode 100644 index 0000000..e03088a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/LocalagentCloudApplication.java @@ -0,0 +1,14 @@ +package com.autopilot.localagent_cloud; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class LocalagentCloudApplication { + + public static void main(String[] args) { + SpringApplication.run(LocalagentCloudApplication.class, args); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/MvpUiViewController.java b/master/src/main/java/com/autopilot/localagent_cloud/MvpUiViewController.java new file mode 100644 index 0000000..513803b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/MvpUiViewController.java @@ -0,0 +1,30 @@ +package com.autopilot.localagent_cloud; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class MvpUiViewController { + + @GetMapping({ + "/autopilot", + "/autopilot/", + "/autopilot/login", + "/autopilot/register", + "/autopilot/dashboard", + "/autopilot/scheduler", + "/autopilot/scheduler/**", + "/autopilot/groups", + "/autopilot/groups/**", + "/autopilot/test-cases", + "/autopilot/test-cases/**", + "/autopilot/test-case-groups", + "/autopilot/test-case-groups/**", + "/autopilot/test-suites", + "/autopilot/test-suites/**", + "/autopilot/stub/**" + }) + public String dashboardRedirect() { + return "forward:/autopilot/index.html"; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/aiengine/DecisionEngineController.java b/master/src/main/java/com/autopilot/localagent_cloud/aiengine/DecisionEngineController.java new file mode 100644 index 0000000..b8ed467 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/aiengine/DecisionEngineController.java @@ -0,0 +1,67 @@ +package com.autopilot.localagent_cloud.aiengine; + +import java.util.Map; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.autopilot.localagent_cloud.service.ExecutionService; + +/** + * Intelligent Decision Engine Layer + * Implements rule-based validation and AI risk models. + */ +@RestController +@RequestMapping("/api/ai-engine") +public class DecisionEngineController { + + private final ExecutionService executionService; + + public DecisionEngineController(ExecutionService executionService) { + this.executionService = executionService; + } + + @SuppressWarnings("unchecked") + @GetMapping("/predict-release-risk") + public ResponseEntity> predictRisk(@RequestParam Long executionId) { + ResponseEntity> res = executionService.getById(executionId); + if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) { + return ResponseEntity.status(res.getStatusCode()).build(); + } + + Map executionData = res.getBody(); + Map execution = (Map) executionData.get("execution"); + + int passed = execution.get("passedCount") != null ? ((Number) execution.get("passedCount")).intValue() : 0; + int failed = execution.get("failedCount") != null ? ((Number) execution.get("failedCount")).intValue() : 0; + + String decision; + double qualityScore; + String[] riskFactors; + + if (failed > 0) { + decision = "REJECTED_HIGH_RISK"; + qualityScore = 0.0; + riskFactors = new String[]{failed + " tests failed during execution"}; + } else if (passed > 0 && failed == 0) { + decision = "APPROVED"; + qualityScore = 1.0; + riskFactors = new String[]{"All tests passed successfully"}; + } else { + decision = "CONDITIONAL_DEPLOYMENT"; + qualityScore = 0.5; + riskFactors = new String[]{"Insufficient test data. " + passed + " passed, " + failed + " failed"}; + } + + return ResponseEntity.ok(Map.of( + "executionId", executionId, + "qualityScore", qualityScore, + "decision", decision, + "riskFactors", riskFactors + )); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/analytics/AnalyticsController.java b/master/src/main/java/com/autopilot/localagent_cloud/analytics/AnalyticsController.java new file mode 100644 index 0000000..41d951b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/analytics/AnalyticsController.java @@ -0,0 +1,93 @@ +package com.autopilot.localagent_cloud.analytics; + +import java.util.List; +import java.util.Map; + +import com.autopilot.localagent_cloud.service.AnalyticsService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Data Normalization & Quality Feature Engineering + * Handles aggregated metrics, pass/fail rates, and coverage data. + */ +@RestController +@RequestMapping("/api/analytics") +@CrossOrigin(origins = "*") +public class AnalyticsController { + + private final AnalyticsService analyticsService; + + public AnalyticsController(AnalyticsService analyticsService) { + this.analyticsService = analyticsService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping("/dashboard/summary") + public ResponseEntity> getQualitySummary() { + // TODO: Aggregate test data from DB and calculate pass/fail and coverage rates + return ResponseEntity.ok(Map.of( + "totalTests", 0, + "passRate", 0.0, + "flakyTestsDetected", 0 + )); + } + + /** + * GET /api/analytics/flaky-suites + * Returns the top N flakiest test suites for the authenticated org. + */ + @GetMapping("/flaky-suites") + public ResponseEntity>> getFlakySuites( + @RequestParam(defaultValue = "5") int limit, + HttpServletRequest req) { + Long orgId = orgId(req); + List> data = analyticsService.getFlakySuites(orgId, limit); + return ResponseEntity.ok(data); + } + + /** + * GET /api/analytics/fleet-health + * Returns live fleet status: agent statuses, counts, and queue depth. + */ + @GetMapping("/fleet-health") + public ResponseEntity> getFleetHealth(HttpServletRequest req) { + Long orgId = orgId(req); + Map data = analyticsService.getFleetHealth(orgId); + return ResponseEntity.ok(data); + } + + /** + * GET /api/analytics/suite-performance + * Returns ranked list of test suites by success rate, avg duration, and run count. + */ + @GetMapping("/suite-performance") + public ResponseEntity>> getSuitePerformance( + @RequestParam(defaultValue = "10") int limit, + HttpServletRequest req) { + Long orgId = orgId(req); + List> data = analyticsService.getSuitePerformance(orgId, limit); + return ResponseEntity.ok(data); + } + + /** + * GET /api/analytics/time-saved-roi + * Returns ROI metrics: hours saved, dollar value saved, runs automated, + * vs estimated manual testing time. Includes 14-day daily breakdown. + */ + @GetMapping("/time-saved-roi") + public ResponseEntity> getTimeSavedRoi(HttpServletRequest req) { + Long orgId = orgId(req); + Map data = analyticsService.getTimeSavedRoi(orgId); + return ResponseEntity.ok(data); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/AppUserDetailsService.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/AppUserDetailsService.java new file mode 100644 index 0000000..59c4c93 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/AppUserDetailsService.java @@ -0,0 +1,28 @@ +package com.autopilot.localagent_cloud.auth; +import java.util.List; + +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import com.autopilot.localagent_cloud.model.AppUser; +import com.autopilot.localagent_cloud.repository.AppUserRepository; + +@Service +public class AppUserDetailsService implements UserDetailsService { + private final AppUserRepository userRepository; + public AppUserDetailsService(AppUserRepository userRepository) { this.userRepository = userRepository; } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + AppUser user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + email)); + return new org.springframework.security.core.userdetails.User( + user.getEmail(), + user.getPasswordHash(), + List.of(new SimpleGrantedAuthority("ROLE_" + user.getRole().toUpperCase())) + ); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/AuthController.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/AuthController.java new file mode 100644 index 0000000..eb3ca10 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/AuthController.java @@ -0,0 +1,293 @@ +package com.autopilot.localagent_cloud.auth; +import com.autopilot.localagent_cloud.model.*; +import com.autopilot.localagent_cloud.repository.*; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import java.util.Map; +import java.util.HashMap; +import java.util.UUID; + +@RestController +@RequestMapping("/api/auth") +@CrossOrigin(origins = "*") +public class AuthController { + private final AppUserRepository userRepository; + private final OrganisationRepository orgRepository; + private final AgentTokenRepository agentTokenRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public AuthController(AppUserRepository userRepository, + OrganisationRepository orgRepository, + AgentTokenRepository agentTokenRepository, + PasswordEncoder passwordEncoder, + JwtUtil jwtUtil) { + this.userRepository = userRepository; + this.orgRepository = orgRepository; + this.agentTokenRepository = agentTokenRepository; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + } + + /** POST /api/auth/register — creates an org + user, returns JWT */ + @PostMapping("/register") + public ResponseEntity> register(@RequestBody Map body) { + String email = body.get("email"); + String password = body.get("password"); + String fullName = body.getOrDefault("fullName", ""); + String orgName = body.getOrDefault("orgName", fullName + "'s Organisation"); + + if (email == null || password == null || email.isBlank() || password.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "Email and password are required")); + } + if (userRepository.existsByEmail(email)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "Email already registered")); + } + + // Create organisation + Organisation org = new Organisation(); + org.setName(orgName); + org.setPlan("trial"); + + // Generate subdomain + String baseSlug = orgName.toLowerCase().replaceAll("[^a-z0-9]+", "-"); + if (baseSlug.startsWith("-")) baseSlug = baseSlug.substring(1); + if (baseSlug.endsWith("-")) baseSlug = baseSlug.substring(0, baseSlug.length() - 1); + if (baseSlug.isBlank()) baseSlug = "org"; + + String slug = baseSlug; + int counter = 1; + while (orgRepository.findBySubdomain(slug) != null) { + slug = baseSlug + "-" + counter; + counter++; + } + org.setSubdomain(slug); + + // Generate unique public ID like VIT-49281 + String namePrefix = orgName.replaceAll("[^a-zA-Z0-9]", "").toUpperCase(); + namePrefix = namePrefix.length() >= 3 ? namePrefix.substring(0, 3) : namePrefix; + String publicId; + do { + publicId = namePrefix + "-" + String.format("%05d", (int)(Math.random() * 99999)); + } while (orgRepository.findByPublicId(publicId) != null); + org.setPublicId(publicId); + org = orgRepository.save(org); + + // Create user + AppUser user = new AppUser(); + user.setOrgId(org.getId()); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setFullName(fullName); + user.setRole("admin"); + user = userRepository.save(user); + + // Auto-generate first agent token + AgentToken token = new AgentToken(); + token.setOrgId(org.getId()); + token.setToken("agt_" + UUID.randomUUID().toString().replace("-", "")); + token.setLabel("Default Agent"); + agentTokenRepository.save(token); + + String jwt = jwtUtil.generateToken(user.getEmail(), org.getId(), user.getRole()); + Map registerResp = new HashMap<>(); + registerResp.put("token", jwt); + registerResp.put("email", user.getEmail()); + registerResp.put("fullName", user.getFullName() != null ? user.getFullName() : ""); + registerResp.put("orgId", org.getId()); + registerResp.put("orgPublicId", org.getPublicId() != null ? org.getPublicId() : ""); + registerResp.put("orgName", org.getName()); + registerResp.put("subdomain", org.getSubdomain()); + registerResp.put("plan", org.getPlan()); + registerResp.put("role", user.getRole()); + registerResp.put("agentToken", token.getToken()); + registerResp.put("requiresPasswordChange", user.isRequiresPasswordChange()); + return ResponseEntity.status(HttpStatus.CREATED).body(registerResp); + } + + /** POST /api/auth/login — verify credentials, return JWT */ + @PostMapping("/login") + public ResponseEntity> login(@RequestBody Map body) { + String email = body.get("email"); + String password = body.get("password"); + if (email == null || password == null) { + return ResponseEntity.badRequest().body(Map.of("error", "Email and password required")); + } + AppUser user = userRepository.findByEmail(email).orElse(null); + if (user == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "No such user. Please create an account.")); + } + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Invalid credentials")); + } + Organisation org = orgRepository.findById(user.getOrgId()).orElseThrow(); + String agentToken = agentTokenRepository.findByOrgId(org.getId()) + .stream().findFirst().map(AgentToken::getToken).orElse(""); + + String jwt = jwtUtil.generateToken(user.getEmail(), org.getId(), user.getRole()); + Map loginResp = new HashMap<>(); + loginResp.put("token", jwt); + loginResp.put("email", user.getEmail()); + loginResp.put("fullName", user.getFullName() != null ? user.getFullName() : ""); + loginResp.put("orgId", org.getId()); + loginResp.put("orgPublicId", org.getPublicId() != null ? org.getPublicId() : ""); + loginResp.put("orgName", org.getName()); + loginResp.put("subdomain", org.getSubdomain()); + loginResp.put("plan", org.getPlan()); + loginResp.put("role", user.getRole()); + loginResp.put("agentToken", agentToken); + loginResp.put("requiresPasswordChange", user.isRequiresPasswordChange()); + return ResponseEntity.ok(loginResp); + } + + /** GET /api/auth/me — get current user info from JWT */ + @GetMapping("/me") + public ResponseEntity> me(jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + Organisation org = orgRepository.findById(orgId).orElse(null); + if (org == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + String agentToken = agentTokenRepository.findByOrgId(orgId) + .stream().findFirst().map(AgentToken::getToken).orElse(""); + AppUser currentUser = userRepository.findByEmail((String) req.getAttribute("email")).orElse(null); + return ResponseEntity.ok(Map.of( + "orgId", orgId, + "orgPublicId", org.getPublicId() != null ? org.getPublicId() : "", + "orgName", org.getName(), + "subdomain", org.getSubdomain(), + "plan", org.getPlan(), + "role", currentUser != null ? currentUser.getRole() : "user", + "agentToken", agentToken, + "requiresPasswordChange", currentUser != null && currentUser.isRequiresPasswordChange() + )); + } + + /** POST /api/auth/agent-tokens — generate a new agent token for the org */ + @PostMapping("/agent-tokens") + public ResponseEntity> createAgentToken( + @RequestBody(required = false) Map body, + jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + AgentToken token = new AgentToken(); + token.setOrgId(orgId); + token.setToken("agt_" + UUID.randomUUID().toString().replace("-", "")); + token.setLabel(body != null ? body.getOrDefault("label", "Agent") : "Agent"); + agentTokenRepository.save(token); + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of( + "token", token.getToken(), + "label", token.getLabel(), + "id", token.getId() + )); + } + /** GET /api/auth/users — list all users in the organization */ + @GetMapping("/users") + public ResponseEntity> getOrgUsers(jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(userRepository.findByOrgId(orgId)); + } + + /** POST /api/auth/users/invite — invite/create a user in the org */ + @PostMapping("/users/invite") + public ResponseEntity> inviteUser( + @RequestBody Map body, + jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + // Only admins can invite users + String callerEmail = (String) req.getAttribute("email"); + AppUser caller = callerEmail != null ? userRepository.findByEmail(callerEmail).orElse(null) : null; + if (caller == null || !"admin".equals(caller.getRole())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "Only administrators can invite users")); + } + + String email = body.get("email"); + String role = body.getOrDefault("role", "user"); + String fullName = body.getOrDefault("fullName", ""); + + if (email == null || email.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "Email is required")); + } + if (userRepository.existsByEmail(email)) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "Email already registered")); + } + + AppUser user = new AppUser(); + user.setOrgId(orgId); + user.setEmail(email); + user.setFullName(fullName); + user.setRole(role); + + // SECURITY FIX: Generate a stronger temporary password (16-char alphanumeric) + // Industry standard: NEVER return plaintext passwords in API responses. + // The password must be shared out-of-band (e.g., email, secure channel). + // We return it ONCE here as it cannot be recovered later — the admin must copy it immediately. + String randomPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + user.setPasswordHash(passwordEncoder.encode(randomPassword)); + user.setRequiresPasswordChange(true); + user = userRepository.save(user); + + // NOTE: In production with email configured, send this to the user's email instead. + // The field is named 'initialPassword' not 'temporaryPassword' to make it clear this + // is a one-time display — it will not appear again after this response. + return ResponseEntity.status(HttpStatus.CREATED).body(Map.of( + "id", user.getId(), + "email", user.getEmail(), + "role", user.getRole(), + "fullName", user.getFullName() != null ? user.getFullName() : "", + "initialPassword", randomPassword, + "notice", "Share this password with the user via a secure channel. It will not be shown again." + )); + } + + /** GET /api/auth/agent-tokens — list all agent tokens in the org */ + @GetMapping("/agent-tokens") + public ResponseEntity> getOrgAgentTokens(jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(agentTokenRepository.findByOrgId(orgId)); + } + + @DeleteMapping("/agent-tokens/{id}") + public ResponseEntity deleteAgentToken( + @PathVariable Long id, + jakarta.servlet.http.HttpServletRequest req) { + Long orgId = (Long) req.getAttribute("orgId"); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + return agentTokenRepository.findById(id).map(token -> { + if (token.getOrgId().equals(orgId)) { + agentTokenRepository.delete(token); + return ResponseEntity.ok().build(); + } else { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + }).orElse(ResponseEntity.notFound().build()); + } + + @PostMapping("/change-password") + public ResponseEntity> changePassword( + @RequestBody Map body, + jakarta.servlet.http.HttpServletRequest req) { + String email = (String) req.getAttribute("email"); + if (email == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + String newPassword = body.get("newPassword"); + if (newPassword == null || newPassword.length() < 8) { + return ResponseEntity.badRequest().body(Map.of("error", "Password must be at least 8 characters")); + } + + AppUser user = userRepository.findByEmail(email).orElse(null); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setRequiresPasswordChange(false); + userRepository.save(user); + + return ResponseEntity.ok(Map.of("message", "Password updated successfully")); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtAuthFilter.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtAuthFilter.java new file mode 100644 index 0000000..631c573 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtAuthFilter.java @@ -0,0 +1,70 @@ +package com.autopilot.localagent_cloud.auth; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import java.util.List; + +import com.autopilot.localagent_cloud.repository.AppUserRepository; + +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final AppUserRepository userRepository; + private final com.autopilot.localagent_cloud.repository.ApiKeyRepository apiKeyRepository; + + public JwtAuthFilter(JwtUtil jwtUtil, AppUserRepository userRepository, com.autopilot.localagent_cloud.repository.ApiKeyRepository apiKeyRepository) { + this.jwtUtil = jwtUtil; + this.userRepository = userRepository; + this.apiKeyRepository = apiKeyRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + String header = req.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + if (jwtUtil.isValid(token)) { + String email = jwtUtil.extractEmail(token); + // Verify user actually exists in DB! (so if DB is wiped, token is invalid) + if (userRepository.existsByEmail(email)) { + Long orgId = jwtUtil.extractOrgId(token); + var claims = jwtUtil.extractClaims(token); + String role = claims.get("role", String.class); + // Store orgId and email in request attribute for controllers to read + req.setAttribute("orgId", orgId); + req.setAttribute("email", email); + var auth = new UsernamePasswordAuthenticationToken( + email, null, + List.of(new SimpleGrantedAuthority("ROLE_" + (role != null ? role.toUpperCase() : "USER"))) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } else if (token.startsWith("ap_live_")) { + // It's an API Key + apiKeyRepository.findByToken(token).ifPresent(key -> { + // Throttle updates: only save if lastUsedAt is null or older than 5 minutes + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + if (key.getLastUsedAt() == null || key.getLastUsedAt().isBefore(now.minusMinutes(5))) { + key.setLastUsedAt(now); + apiKeyRepository.save(key); + } + req.setAttribute("orgId", key.getOrgId()); + var auth = new UsernamePasswordAuthenticationToken( + "api-key-" + key.getId(), null, + List.of(new SimpleGrantedAuthority("ROLE_API")) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + }); + } + } + chain.doFilter(req, res); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtUtil.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtUtil.java new file mode 100644 index 0000000..ff1a7b4 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/JwtUtil.java @@ -0,0 +1,41 @@ +package com.autopilot.localagent_cloud.auth; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import javax.crypto.SecretKey; +import java.util.Date; + +@Component +public class JwtUtil { + private final SecretKey key; + private final long expirationMs; + + public JwtUtil(@Value("${autopilot.jwt.secret}") String secret, + @Value("${autopilot.jwt.expiration-ms}") long expirationMs) { + this.key = Keys.hmacShaKeyFor(secret.getBytes()); + this.expirationMs = expirationMs; + } + + public String generateToken(String email, Long orgId, String role) { + return Jwts.builder() + .subject(email) + .claim("orgId", orgId) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(key) + .compact(); + } + + public Claims extractClaims(String token) { + return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload(); + } + + public String extractEmail(String token) { return extractClaims(token).getSubject(); } + public Long extractOrgId(String token) { return extractClaims(token).get("orgId", Long.class); } + + public boolean isValid(String token) { + try { extractClaims(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/PairingController.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/PairingController.java new file mode 100644 index 0000000..5f2da00 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/PairingController.java @@ -0,0 +1,137 @@ +package com.autopilot.localagent_cloud.auth; + +import com.autopilot.localagent_cloud.model.AgentToken; +import com.autopilot.localagent_cloud.repository.AgentTokenRepository; +import com.autopilot.localagent_cloud.model.AppUser; +import com.autopilot.localagent_cloud.repository.AppUserRepository; +import com.autopilot.localagent_cloud.model.DevicePairing; +import com.autopilot.localagent_cloud.repository.DevicePairingRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Random; +import java.util.UUID; + +@RestController +@RequestMapping("/api") +public class PairingController { + + private final DevicePairingRepository pairingRepository; + private final AgentTokenRepository tokenRepository; + private final AppUserRepository userRepository; + + public PairingController(DevicePairingRepository pairingRepository, AgentTokenRepository tokenRepository, AppUserRepository userRepository) { + this.pairingRepository = pairingRepository; + this.tokenRepository = tokenRepository; + this.userRepository = userRepository; + } + + /** + * Agent calls this to start pairing. + */ + @PostMapping("/agents/pairing/start") + public ResponseEntity> startPairing() { + String code = String.format("%06d", new Random().nextInt(999999)); + + // Ensure uniqueness + while (pairingRepository.findByPairingCode(code).isPresent()) { + code = String.format("%06d", new Random().nextInt(999999)); + } + + DevicePairing pairing = new DevicePairing(); + pairing.setPairingCode(code); + pairing = pairingRepository.save(pairing); + + Map response = new HashMap<>(); + response.put("pairingId", pairing.getId()); + response.put("code", pairing.getPairingCode()); + response.put("expiresAt", pairing.getExpiresAt()); + + return ResponseEntity.ok(response); + } + + /** + * Agent polls this to see if user has verified the code. + */ + @GetMapping("/agents/pairing/{id}/status") + public ResponseEntity> checkStatus(@PathVariable Long id) { + Optional opt = pairingRepository.findById(id); + if (opt.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + DevicePairing pairing = opt.get(); + Map response = new HashMap<>(); + + if (LocalDateTime.now().isAfter(pairing.getExpiresAt()) && "PENDING".equals(pairing.getStatus())) { + pairing.setStatus("EXPIRED"); + pairingRepository.save(pairing); + } + + response.put("status", pairing.getStatus()); + if ("PAIRED".equals(pairing.getStatus())) { + response.put("token", pairing.getAgentToken()); + } + + return ResponseEntity.ok(response); + } + + /** + * User (via Web Dashboard) verifies the 6 digit code. + */ + @PostMapping("/pairing/verify") + public ResponseEntity> verifyCode(@RequestBody Map body) { + String code = body.get("code"); + if (code == null || code.trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Code is required")); + } + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String email = auth.getName(); + Optional userOpt = userRepository.findByEmail(email); + + if (userOpt.isEmpty()) { + return ResponseEntity.status(401).build(); + } + + Long orgId = userOpt.get().getOrgId(); + + Optional pairingOpt = pairingRepository.findByPairingCode(code); + if (pairingOpt.isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Invalid pairing code")); + } + + DevicePairing pairing = pairingOpt.get(); + + if (!"PENDING".equals(pairing.getStatus())) { + return ResponseEntity.badRequest().body(Map.of("error", "Code is already paired or expired")); + } + + if (LocalDateTime.now().isAfter(pairing.getExpiresAt())) { + pairing.setStatus("EXPIRED"); + pairingRepository.save(pairing); + return ResponseEntity.badRequest().body(Map.of("error", "Code has expired")); + } + + // Generate a new Agent Token for the organization + AgentToken newToken = new AgentToken(); + newToken.setOrgId(orgId); + newToken.setToken("agt_" + UUID.randomUUID().toString().replace("-", "")); + newToken.setLabel("Agent paired via device code"); + tokenRepository.save(newToken); + + // Mark pairing as complete + pairing.setStatus("PAIRED"); + pairing.setOrgId(orgId); + pairing.setAgentToken(newToken.getToken()); + pairingRepository.save(pairing); + + return ResponseEntity.ok(Map.of("message", "Agent successfully paired")); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/auth/SecurityConfig.java b/master/src/main/java/com/autopilot/localagent_cloud/auth/SecurityConfig.java new file mode 100644 index 0000000..3343edf --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/auth/SecurityConfig.java @@ -0,0 +1,55 @@ +package com.autopilot.localagent_cloud.auth; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + private final JwtAuthFilter jwtAuthFilter; + public SecurityConfig(JwtAuthFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex.authenticationEntryPoint((req, res, e) -> res.sendError(jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"))) + .authorizeHttpRequests(auth -> auth + // Public endpoints + .requestMatchers("/api/auth/**").permitAll() + // Agent polling uses token-based auth (not JWT), so permit it + .requestMatchers("/api/agents/*/jobs/next").permitAll() + .requestMatchers("/api/agents/register").permitAll() + .requestMatchers("/api/agents/*/heartbeat").permitAll() + .requestMatchers("/api/agents/pairing/**").permitAll() + .requestMatchers("/api/executions/*/results").permitAll() + // Legacy agent endpoints + .requestMatchers("/agents/**").permitAll() + .requestMatchers("/executions/**").permitAll() + // Static frontend and assets + .requestMatchers("/autopilot/**", "/", "/index.html").permitAll() + .requestMatchers("/assets/**", "/*.js", "/*.css", "/*.json", "/*.png", "/*.svg", "/*.ico").permitAll() + .requestMatchers("/*.ps1", "/*.sh", "/agent/**").permitAll() + // Everything else requires JWT + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/AgentController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/AgentController.java new file mode 100644 index 0000000..cf21f49 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/AgentController.java @@ -0,0 +1,48 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Agent; +import com.autopilot.localagent_cloud.model.AgentGroupMapping; +import com.autopilot.localagent_cloud.service.AgentService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/agents") +@CrossOrigin(origins = "*") +public class AgentController { + + private final AgentService agentService; + + public AgentController(AgentService agentService) { + this.agentService = agentService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getAgents(HttpServletRequest req) { + return agentService.getAll(orgId(req)); + } + + @PostMapping("/register") + public ResponseEntity> registerAgent(@RequestBody Map body) { + return agentService.register(body); + } + + @PostMapping("/{id}/heartbeat") + public ResponseEntity agentHeartbeat(@PathVariable("id") String id) { + return agentService.heartbeat(id); + } + + @GetMapping("/{id}/jobs/next") + public ResponseEntity> getNextJob(@PathVariable("id") String agentId) { + return agentService.getNextJob(agentId); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/ApiKeyController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/ApiKeyController.java new file mode 100644 index 0000000..aec630a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/ApiKeyController.java @@ -0,0 +1,91 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.ApiKey; +import com.autopilot.localagent_cloud.model.AuditLog; +import com.autopilot.localagent_cloud.repository.ApiKeyRepository; +import com.autopilot.localagent_cloud.repository.AuditLogRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/apikeys") +public class ApiKeyController { + + private final ApiKeyRepository apiKeyRepository; + private final AuditLogRepository auditLogRepository; + + public ApiKeyController(ApiKeyRepository apiKeyRepository, AuditLogRepository auditLogRepository) { + this.apiKeyRepository = apiKeyRepository; + this.auditLogRepository = auditLogRepository; + } + + private String getUserEmail(HttpServletRequest req) { + Object e = req.getAttribute("email"); + return e != null ? e.toString() : "API_KEY"; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getKeys(HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(apiKeyRepository.findByOrgId(orgId)); + } + + @PostMapping + public ResponseEntity createKey(@RequestBody Map body, HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + String name = body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "Key name cannot be blank.")); + } + + ApiKey key = new ApiKey(); + key.setOrgId(orgId); + key.setName(name); + key.setToken("ap_live_" + UUID.randomUUID().toString().replace("-", "")); + + ApiKey saved = apiKeyRepository.save(key); + auditLogRepository.save(new AuditLog(orgId, getUserEmail(req), "CREATE", "API_KEY", saved.getId().toString(), "Generated API Key: " + saved.getName())); + return ResponseEntity.status(HttpStatus.CREATED).body(saved); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteKey(@PathVariable Long id, HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + return apiKeyRepository.findById(id).map(key -> { + if (!key.getOrgId().equals(orgId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + apiKeyRepository.delete(key); + auditLogRepository.save(new AuditLog(orgId, getUserEmail(req), "DELETE", "API_KEY", key.getId().toString(), "Revoked API Key: " + key.getName())); + return ResponseEntity.noContent().build(); + }).orElse(ResponseEntity.notFound().build()); + } + + /** + * Called internally (e.g., by future API middleware) to record when an API key was last used. + * Fix: api_keys.last_used_at was never being updated anywhere. + */ + public void recordKeyUsage(String token) { + apiKeyRepository.findByToken(token).ifPresent(key -> { + key.setLastUsedAt(LocalDateTime.now()); + apiKeyRepository.save(key); + }); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/AuditLogController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/AuditLogController.java new file mode 100644 index 0000000..fc47cc9 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/AuditLogController.java @@ -0,0 +1,35 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.AuditLog; +import com.autopilot.localagent_cloud.repository.AuditLogRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/audit-logs") +public class AuditLogController { + + private final AuditLogRepository auditLogRepository; + + public AuditLogController(AuditLogRepository auditLogRepository) { + this.auditLogRepository = auditLogRepository; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getAuditLogs(HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + List logs = auditLogRepository.findByOrgIdOrderByCreatedAtDesc(orgId); + return ResponseEntity.ok(logs); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/DatasetController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/DatasetController.java new file mode 100644 index 0000000..bcc9512 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/DatasetController.java @@ -0,0 +1,215 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Dataset; +import com.autopilot.localagent_cloud.repository.DatasetRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/datasets") +@CrossOrigin(origins = "*") +public class DatasetController { + + private final DatasetRepository datasetRepository; + + public DatasetController(DatasetRepository datasetRepository) { + this.datasetRepository = datasetRepository; + } + + private Long getOrgId(HttpServletRequest request) { + Object o = request.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + // GET /api/datasets — list all datasets for org + @GetMapping + public ResponseEntity> getAll(HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(datasetRepository.findByOrgId(orgId)); + } + + // GET /api/datasets/{id} — get single dataset by id + @GetMapping("/{id}") + public ResponseEntity getById(@PathVariable Long id, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return datasetRepository.findById(id) + .filter(d -> orgId.equals(d.getOrgId())) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + // POST /api/datasets — create dataset from JSON body + @PostMapping + public ResponseEntity create(@RequestBody Dataset dataset, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + dataset.setOrgId(orgId); + return ResponseEntity.ok(datasetRepository.save(dataset)); + } + + // POST /api/datasets/upload — create dataset from CSV file upload + @PostMapping("/upload") + public ResponseEntity uploadCsv(@RequestParam("file") MultipartFile file, + @RequestParam("name") String name, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(file.getInputStream(), StandardCharsets.UTF_8))) { + + List allRows = new ArrayList<>(); + String line; + while ((line = reader.readLine()) != null) { + if (!line.trim().isEmpty()) { + allRows.add(parseCsvLine(line)); + } + } + + if (allRows.isEmpty()) { + return ResponseEntity.badRequest() + .body(Map.of("error", "CSV file is empty")); + } + + String[] headerRow = allRows.get(0); + List dataRows = allRows.subList(1, allRows.size()); + + String headersJson = toJsonArray(headerRow); + String rowsJson = toJsonArrayOfArrays(dataRows); + + Dataset dataset = new Dataset(); + dataset.setOrgId(orgId); + dataset.setName(name); + dataset.setHeaders(headersJson); + dataset.setRows(rowsJson); + dataset.setRowCount(dataRows.size()); + + return ResponseEntity.ok(datasetRepository.save(dataset)); + + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Failed to parse CSV: " + e.getMessage())); + } + } + + // PUT /api/datasets/{id} — update dataset + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody Dataset body, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + return datasetRepository.findById(id) + .filter(d -> orgId.equals(d.getOrgId())) + .map(d -> { + if (body.getName() != null) d.setName(body.getName()); + if (body.getDescription() != null) d.setDescription(body.getDescription()); + if (body.getHeaders() != null) d.setHeaders(body.getHeaders()); + if (body.getRows() != null) d.setRows(body.getRows()); + if (body.getRowCount() != null) d.setRowCount(body.getRowCount()); + return ResponseEntity.ok(datasetRepository.save(d)); + }).orElse(ResponseEntity.notFound().build()); + } + + // DELETE /api/datasets/{id} — delete dataset + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return datasetRepository.findById(id) + .filter(d -> orgId.equals(d.getOrgId())) + .map(d -> { datasetRepository.delete(d); return ResponseEntity.ok().build(); }) + .orElse(ResponseEntity.notFound().build()); + } + + // --- CSV Parsing Helpers --- + + /** + * Parses a single CSV line, handling quoted fields that may contain commas. + */ + private String[] parseCsvLine(String line) { + List fields = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean inQuotes = false; + + for (int i = 0; i < line.length(); i++) { + char c = line.charAt(i); + if (inQuotes) { + if (c == '"') { + // Check for escaped quote "" + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + current.append('"'); + i++; + } else { + inQuotes = false; + } + } else { + current.append(c); + } + } else { + if (c == '"') { + inQuotes = true; + } else if (c == ',') { + fields.add(current.toString().trim()); + current = new StringBuilder(); + } else { + current.append(c); + } + } + } + fields.add(current.toString().trim()); + return fields.toArray(new String[0]); + } + + /** + * Converts a String array to a JSON array string, e.g. ["a","b","c"] + */ + private String toJsonArray(String[] items) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < items.length; i++) { + if (i > 0) sb.append(","); + sb.append("\"").append(escapeJson(items[i])).append("\""); + } + sb.append("]"); + return sb.toString(); + } + + /** + * Converts a list of String arrays to a JSON array of arrays string. + */ + private String toJsonArrayOfArrays(List rows) { + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < rows.size(); i++) { + if (i > 0) sb.append(","); + sb.append(toJsonArray(rows.get(i))); + } + sb.append("]"); + return sb.toString(); + } + + /** + * Escapes special characters for JSON string values. + */ + private String escapeJson(String value) { + if (value == null) return ""; + return value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java new file mode 100644 index 0000000..578cf7d --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/EnvironmentController.java @@ -0,0 +1,133 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.AuditLog; +import com.autopilot.localagent_cloud.model.Environment; +import com.autopilot.localagent_cloud.model.Variable; +import com.autopilot.localagent_cloud.repository.AuditLogRepository; +import com.autopilot.localagent_cloud.repository.EnvironmentRepository; +import com.autopilot.localagent_cloud.repository.VariableRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** Secret masking sentinel — a clean ASCII constant, never a corrupted UTF-8 literal */ +@RestController +@RequestMapping("/api/environments") +public class EnvironmentController { + + // Industry-standard sentinel string for masked secrets (never stored in DB) + private static final String SECRET_MASK = "••••••••"; + + private final EnvironmentRepository environmentRepository; + private final VariableRepository variableRepository; + private final AuditLogRepository auditLogRepository; + + public EnvironmentController(EnvironmentRepository environmentRepository, + VariableRepository variableRepository, + AuditLogRepository auditLogRepository) { + this.environmentRepository = environmentRepository; + this.variableRepository = variableRepository; + this.auditLogRepository = auditLogRepository; + } + + private String getUserEmail(HttpServletRequest req) { + Object e = req.getAttribute("email"); + return e != null ? e.toString() : "SYSTEM"; + } + + private Long getOrgId(HttpServletRequest request) { + // Use the orgId already extracted and validated by JwtAuthFilter — consistent with all other controllers + Object o = request.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getAll(HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(environmentRepository.findByOrgId(orgId)); + } + + @PostMapping + public ResponseEntity create(@RequestBody Environment env, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + env.setOrgId(orgId); + Environment saved = environmentRepository.save(env); + auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "CREATE", "ENVIRONMENT", saved.getId().toString(), "Created Environment: " + saved.getName())); + return ResponseEntity.ok(saved); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody Environment body, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return environmentRepository.findById(id) + .filter(e -> orgId.equals(e.getOrgId())) // Tenant isolation check + .map(e -> { + e.setName(body.getName()); + e.setDescription(body.getDescription()); + Environment saved = environmentRepository.save(e); + auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "UPDATE", "ENVIRONMENT", saved.getId().toString(), "Updated Environment: " + saved.getName())); + return ResponseEntity.ok(saved); + }).orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + // Bug fix: verify this environment belongs to the calling org before deleting + Environment env = environmentRepository.findById(id).orElse(null); + if (env == null) return ResponseEntity.notFound().build(); + if (!orgId.equals(env.getOrgId())) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + + // Bug fix: pass correct orgId (was passing null — could delete other org's variables!) + List envVars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id); + variableRepository.deleteAll(envVars); + environmentRepository.deleteById(id); + auditLogRepository.save(new AuditLog(orgId, getUserEmail(request), "DELETE", "ENVIRONMENT", id.toString(), "Deleted Environment: " + env.getName())); + return ResponseEntity.ok().build(); + } + + @GetMapping("/{id}/variables") + public ResponseEntity> getVariables(@PathVariable Long id, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + List vars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, "ENVIRONMENT", id); + // Mask secret values before returning to client + vars.forEach(v -> { if (Boolean.TRUE.equals(v.getIsSecret())) v.setValue(SECRET_MASK); }); + return ResponseEntity.ok(vars); + } + + @PostMapping("/{id}/variables") + public ResponseEntity addVariable(@PathVariable Long id, + @RequestBody Variable variable, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + variable.setOrgId(orgId); + variable.setScope("ENVIRONMENT"); + variable.setScopeId(id); + return ResponseEntity.ok(variableRepository.save(variable)); + } + + @DeleteMapping("/{envId}/variables/{varId}") + public ResponseEntity deleteVariable(@PathVariable Long envId, + @PathVariable Long varId, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + // Verify the variable belongs to this org before deleting + return variableRepository.findById(varId) + .filter(v -> orgId.equals(v.getOrgId())) + .map(v -> { variableRepository.delete(v); return ResponseEntity.ok().build(); }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/ExecutionController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/ExecutionController.java new file mode 100644 index 0000000..71f365a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/ExecutionController.java @@ -0,0 +1,62 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Execution; +import com.autopilot.localagent_cloud.service.AgentService; +import com.autopilot.localagent_cloud.service.ExecutionService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/executions") +@CrossOrigin(origins = "*") +public class ExecutionController { + + private final ExecutionService executionService; + private final AgentService agentService; + + public ExecutionController(ExecutionService executionService, AgentService agentService) { + this.executionService = executionService; + this.agentService = agentService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getExecutions(HttpServletRequest req) { + return executionService.getAll(orgId(req)); + } + + @GetMapping("/{id}") + public ResponseEntity> getExecutionDetail(@PathVariable("id") Long id) { + return executionService.getById(id); + } + + @PostMapping("/{id}/results") + public ResponseEntity postExecutionResults( + @PathVariable("id") Long executionId, + @RequestBody Map result) { + return agentService.postResults(executionId, result); + } + + @PostMapping("/{id}/stop") + public ResponseEntity stopExecution(@PathVariable("id") Long executionId) { + return agentService.stopExecution(executionId); + } + + @PostMapping("/{id}/rerun") + public ResponseEntity rerunExecution(@PathVariable("id") Long executionId) { + return agentService.rerunExecution(executionId); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteExecution(@PathVariable("id") Long executionId) { + return executionService.deleteById(executionId); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/GroupController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/GroupController.java new file mode 100644 index 0000000..5f867d5 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/GroupController.java @@ -0,0 +1,67 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Group; +import com.autopilot.localagent_cloud.model.AgentGroupMapping; +import com.autopilot.localagent_cloud.service.GroupService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/groups") +@CrossOrigin(origins = "*") +public class GroupController { + + private final GroupService groupService; + + public GroupController(GroupService groupService) { + this.groupService = groupService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getGroups(HttpServletRequest req) { + return groupService.getAll(orgId(req)); + } + + @PostMapping + public ResponseEntity createGroup(@RequestBody Group group, HttpServletRequest req) { + return groupService.create(group, orgId(req)); + } + + @PutMapping("/{id}") + public ResponseEntity updateGroup(@PathVariable("id") Long id, @RequestBody Group updated) { + return groupService.update(id, updated); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteGroup(@PathVariable("id") Long id) { + return groupService.delete(id); + } + + @GetMapping("/{id}/agents") + public ResponseEntity>> getAgentsInGroup(@PathVariable("id") Long groupId) { + return groupService.getAgentsInGroup(groupId); + } + + @PostMapping("/{id}/agents") + public ResponseEntity addAgentToGroup( + @PathVariable("id") Long groupId, + @RequestBody Map body) { + return groupService.addAgent(groupId, body.get("agentId")); + } + + @DeleteMapping("/{groupId}/agents/{agentId}") + public ResponseEntity removeAgentFromGroup( + @PathVariable("groupId") Long groupId, + @PathVariable("agentId") String agentId) { + return groupService.removeAgent(groupId, agentId); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/PipelineController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/PipelineController.java new file mode 100644 index 0000000..68e361b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/PipelineController.java @@ -0,0 +1,228 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.TestSuite; +import com.autopilot.localagent_cloud.repository.TestSuiteRepository; +import com.autopilot.localagent_cloud.service.ExecutionService; +import com.autopilot.localagent_cloud.service.TestSuiteService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Gap 4: CI/CD Pipeline Integration Controller + * + * Provides API-key-authenticated endpoints that DevOps tools (Jenkins, GitHub Actions, + * GitLab CI) can call to trigger test suite executions and poll for results. + * + * Authentication: Pass your AutoPilot API Key in the Authorization header: + * Authorization: Bearer ap_live_ + */ +@RestController +@RequestMapping("/api/v1") +public class PipelineController { + + private final TestSuiteService testSuiteService; + private final ExecutionService executionService; + private final TestSuiteRepository testSuiteRepository; + + public PipelineController(TestSuiteService testSuiteService, + ExecutionService executionService, + TestSuiteRepository testSuiteRepository) { + this.testSuiteService = testSuiteService; + this.executionService = executionService; + this.testSuiteRepository = testSuiteRepository; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + /** + * Trigger a test suite by its ID. + * Supports optional body overrides: browser, environmentId, targetGroupId. + * + * POST /api/v1/suites/{id}/trigger + * Body (optional): + * { + * "browser": "firefox", + * "environmentId": 2, + * "targetGroupId": 1 + * } + */ + @PostMapping("/suites/{id}/trigger") + public ResponseEntity> triggerSuiteById( + @PathVariable("id") Long id, + @RequestBody(required = false) Map overrides, + HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + Map runBody = overrides != null ? new HashMap<>(overrides) : new HashMap<>(); + return executeAndBuildResponse(id, runBody, orgId); + } + + /** + * Trigger a test suite by its exact name. Useful for CI scripts where you know + * the suite name but not the numeric ID. + * + * POST /api/v1/suites/trigger-by-name + * Body: + * { + * "suiteName": "Smoke Tests", + * "browser": "chrome", + * "environmentId": 1 + * } + */ + @PostMapping("/suites/trigger-by-name") + public ResponseEntity> triggerSuiteByName( + @RequestBody Map body, + HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + String suiteName = (String) body.get("suiteName"); + if (suiteName == null || suiteName.isBlank()) { + Map err = new HashMap<>(); + err.put("error", "suiteName is required"); + return ResponseEntity.badRequest().body(err); + } + + Optional suite = testSuiteRepository.findAll().stream() + .filter(s -> orgId.equals(s.getOrgId()) && suiteName.equalsIgnoreCase(s.getName())) + .findFirst(); + + if (suite.isEmpty()) { + Map err = new HashMap<>(); + err.put("error", "Test Suite not found: " + suiteName); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(err); + } + + Map runBody = new HashMap<>(body); + runBody.remove("suiteName"); + return executeAndBuildResponse(suite.get().getId(), runBody, orgId); + } + + /** + * Poll the status of an execution by its ID. + * CI tools can call this in a loop to wait for completion. + * + * GET /api/v1/executions/{id}/status + */ + @SuppressWarnings("unchecked") + @GetMapping("/executions/{id}/status") + public ResponseEntity> getExecutionStatus( + @PathVariable("id") Long id, + HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + ResponseEntity> res = executionService.getById(id); + if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) { + Map queuedResponse = new HashMap<>(); + queuedResponse.put("status", "queued"); + queuedResponse.put("message", "Waiting for agent to pick up the job..."); + return ResponseEntity.ok(queuedResponse); + } + + return ResponseEntity.ok(buildExecutionStatusResponse(res.getBody())); + } + + /** + * Poll the latest execution status for a scheduler/trigger job. + * + * GET /api/v1/schedulers/{id}/execution-status + */ + @SuppressWarnings("unchecked") + @GetMapping("/schedulers/{id}/execution-status") + public ResponseEntity> getSchedulerExecutionStatus( + @PathVariable("id") Long id, + HttpServletRequest req) { + Long orgId = orgId(req); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + ResponseEntity> res = executionService.getBySchedulerId(id); + if (res.getStatusCode() != HttpStatus.OK || res.getBody() == null) { + Map queuedResponse = new HashMap<>(); + queuedResponse.put("status", "queued"); + queuedResponse.put("message", "Waiting for Local Agent to pick up the job..."); + return ResponseEntity.ok(queuedResponse); + } + + return ResponseEntity.ok(buildExecutionStatusResponse(res.getBody())); + } + + // ───────────────────────────────────────────────────────────────────────── + + private ResponseEntity> executeAndBuildResponse(Long suiteId, Map runBody, Long orgId) { + try { + ResponseEntity> res = testSuiteService.run(suiteId, runBody, orgId); + + if (res.getStatusCode() == HttpStatus.OK || res.getStatusCode() == HttpStatus.CREATED) { + Map resBody = res.getBody(); + if (resBody != null && resBody.containsKey("scheduler")) { + com.autopilot.localagent_cloud.model.Scheduler sched = + (com.autopilot.localagent_cloud.model.Scheduler) resBody.get("scheduler"); + Map response = new HashMap<>(); + response.put("schedulerId", sched.getId()); + response.put("suiteId", suiteId); + response.put("status", "queued"); + response.put("message", "Test Suite queued successfully."); + response.put("statusUrl", "/api/v1/schedulers/" + sched.getId() + "/execution-status"); + return ResponseEntity.ok(response); + } + } + return ResponseEntity.status(res.getStatusCode()).body(res.getBody()); + } catch (Exception e) { + Map err = new HashMap<>(); + err.put("error", "Failed to trigger Test Suite"); + err.put("details", e.getMessage()); + return ResponseEntity.badRequest().body(err); + } + } + + @SuppressWarnings("unchecked") + private Map buildExecutionStatusResponse(Map executionData) { + com.autopilot.localagent_cloud.model.Execution execution = + (com.autopilot.localagent_cloud.model.Execution) executionData.get("execution"); + java.util.List steps = + (java.util.List) executionData.get("steps"); + + int passed = 0, failed = 0; + if (steps != null) { + for (com.autopilot.localagent_cloud.model.StepResult step : steps) { + if (step.getResultStatus() != null && step.getResultStatus() == 1) passed++; + else if (step.getResultStatus() != null && step.getResultStatus() == 2) failed++; + } + } + int total = passed + failed; + double passPercentage = total > 0 ? ((double) passed / total) * 100.0 : 0.0; + + long durationMs = 0; + if (execution.getCreatedAt() != null && execution.getFinishedAt() != null) { + durationMs = java.time.Duration.between(execution.getCreatedAt(), execution.getFinishedAt()).toMillis(); + } + + // CI/CD friendly exit code: 0 = success, 1 = failed, 2 = still running + int exitCode = "completed".equals(execution.getStatus()) ? (failed > 0 ? 1 : 0) : 2; + + Map response = new HashMap<>(); + response.put("executionId", execution.getId()); + response.put("status", execution.getStatus()); + response.put("exitCode", exitCode); + response.put("passedCount", passed); + response.put("failedCount", failed); + response.put("totalCount", total); + response.put("passPercentage", Math.round(passPercentage * 100.0) / 100.0); + response.put("durationMs", durationMs); + response.put("aiAnalysis", execution.getAiAnalysis()); + response.put("startedAt", execution.getCreatedAt()); + response.put("completedAt", execution.getFinishedAt()); + return response; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/PublicOrgController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/PublicOrgController.java new file mode 100644 index 0000000..9dd4ca6 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/PublicOrgController.java @@ -0,0 +1,33 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Organisation; +import com.autopilot.localagent_cloud.repository.OrganisationRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/public/orgs") +@CrossOrigin(origins = "*") +public class PublicOrgController { + + private final OrganisationRepository orgRepository; + + public PublicOrgController(OrganisationRepository orgRepository) { + this.orgRepository = orgRepository; + } + + @GetMapping("/by-subdomain/{subdomain}") + public ResponseEntity> getOrgBySubdomain(@PathVariable String subdomain) { + Organisation org = orgRepository.findBySubdomain(subdomain); + if (org == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(Map.of( + "name", org.getName(), + "subdomain", org.getSubdomain() + )); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/SchedulerController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/SchedulerController.java new file mode 100644 index 0000000..424f188 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/SchedulerController.java @@ -0,0 +1,47 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.service.SchedulerService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedulers") +@CrossOrigin(origins = "*") +public class SchedulerController { + + private final SchedulerService schedulerService; + + public SchedulerController(SchedulerService schedulerService) { + this.schedulerService = schedulerService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getSchedulers(HttpServletRequest req) { + return schedulerService.getAll(orgId(req)); + } + + @PostMapping + public ResponseEntity createScheduler(@RequestBody Map body, HttpServletRequest req) { + return schedulerService.create(body, orgId(req)); + } + + @PutMapping("/{id}") + public ResponseEntity updateScheduler(@PathVariable("id") Long id, @RequestBody Map body) { + return schedulerService.update(id, body); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteScheduler(@PathVariable("id") Long id) { + return schedulerService.delete(id); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/ScreenshotController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/ScreenshotController.java new file mode 100644 index 0000000..ce17ce6 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/ScreenshotController.java @@ -0,0 +1,64 @@ +package com.autopilot.localagent_cloud.controller; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.io.File; +import java.net.MalformedURLException; +import java.nio.file.Path; +import java.nio.file.Paths; + +@RestController +@RequestMapping("/api/screenshots") +public class ScreenshotController { + + private static final Logger logger = LoggerFactory.getLogger(ScreenshotController.class); + private static final Path SCREENSHOTS_DIR = Paths.get("data/screenshots").toAbsolutePath().normalize(); + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping("/{fileName:.+}") + public ResponseEntity getScreenshotFile( + @PathVariable("fileName") String fileName, + HttpServletRequest req) { + + // Security: require authenticated user + if (orgId(req) == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + try { + // Security: prevent path traversal by resolving inside the base dir and verifying it stays within + Path resolvedPath = SCREENSHOTS_DIR.resolve(fileName).normalize(); + if (!resolvedPath.startsWith(SCREENSHOTS_DIR)) { + logger.warn("Path traversal attempt blocked: {}", fileName); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + File file = resolvedPath.toFile(); + if (!file.exists() || !file.isFile()) { + return ResponseEntity.notFound().build(); + } + + Resource resource = new UrlResource(file.toURI()); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + file.getName() + "\"") + .contentType(MediaType.IMAGE_PNG) + .body(resource); + } catch (MalformedURLException e) { + logger.error("Failed to serve screenshot file: {}", fileName, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java new file mode 100644 index 0000000..2042f99 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseController.java @@ -0,0 +1,55 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.TestCase; +import com.autopilot.localagent_cloud.service.TestCaseService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/test-cases") +@CrossOrigin(origins = "*") +public class TestCaseController { + + private final TestCaseService testCaseService; + + public TestCaseController(TestCaseService testCaseService) { + this.testCaseService = testCaseService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getTestCases(HttpServletRequest req) { + return testCaseService.getAll(orgId(req)); + } + + @GetMapping("/{id}") + public ResponseEntity> getTestCaseDetail(@PathVariable("id") Long id) { + return testCaseService.getById(id); + } + + @PostMapping + public ResponseEntity> createTestCase( + @RequestBody Map body, HttpServletRequest req) { + return testCaseService.create(body, orgId(req)); + } + + @PutMapping("/{id}") + public ResponseEntity> updateTestCase( + @PathVariable("id") Long id, + @RequestBody Map body) { + return testCaseService.update(id, body); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTestCase(@PathVariable("id") Long id) { + return testCaseService.delete(id); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseGroupController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseGroupController.java new file mode 100644 index 0000000..45f1fbd --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestCaseGroupController.java @@ -0,0 +1,55 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.TestCaseGroup; +import com.autopilot.localagent_cloud.service.TestCaseGroupService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/test-case-groups") +@CrossOrigin(origins = "*") +public class TestCaseGroupController { + + private final TestCaseGroupService testCaseGroupService; + + public TestCaseGroupController(TestCaseGroupService testCaseGroupService) { + this.testCaseGroupService = testCaseGroupService; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getTestCaseGroups(HttpServletRequest req) { + return testCaseGroupService.getAll(orgId(req)); + } + + @GetMapping("/{id}") + public ResponseEntity> getTestCaseGroupDetail(@PathVariable("id") Long id) { + return testCaseGroupService.getById(id); + } + + @PostMapping + public ResponseEntity> createTestCaseGroup( + @RequestBody Map body, HttpServletRequest req) { + return testCaseGroupService.create(body, orgId(req)); + } + + @PutMapping("/{id}") + public ResponseEntity> updateTestCaseGroup( + @PathVariable("id") Long id, + @RequestBody Map body) { + return testCaseGroupService.update(id, body); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTestCaseGroup(@PathVariable("id") Long id) { + return testCaseGroupService.delete(id); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java new file mode 100644 index 0000000..fe3593b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/TestSuiteController.java @@ -0,0 +1,86 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.AuditLog; +import com.autopilot.localagent_cloud.model.TestSuite; +import com.autopilot.localagent_cloud.repository.AuditLogRepository; +import com.autopilot.localagent_cloud.service.TestSuiteService; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/test-suites") +@CrossOrigin(origins = "*") +public class TestSuiteController { + + private final TestSuiteService testSuiteService; + private final AuditLogRepository auditLogRepository; + + public TestSuiteController(TestSuiteService testSuiteService, AuditLogRepository auditLogRepository) { + this.testSuiteService = testSuiteService; + this.auditLogRepository = auditLogRepository; + } + + private String getUserEmail(HttpServletRequest req) { + Object e = req.getAttribute("email"); + return e != null ? e.toString() : "SYSTEM"; + } + + private Long orgId(HttpServletRequest req) { + Object o = req.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getTestSuites(HttpServletRequest req) { + return testSuiteService.getAll(orgId(req)); + } + + @GetMapping("/{id}") + public ResponseEntity> getTestSuiteDetail(@PathVariable("id") Long id) { + return testSuiteService.getById(id); + } + + @PostMapping + public ResponseEntity> createTestSuite( + @RequestBody Map body, HttpServletRequest req) { + ResponseEntity> res = testSuiteService.create(body, orgId(req)); + if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) { + TestSuite suite = (TestSuite) res.getBody().get("suite"); + auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "CREATE", "TEST_SUITE", suite.getId().toString(), "Created Test Suite: " + suite.getName())); + } + return res; + } + + @PutMapping("/{id}") + public ResponseEntity> updateTestSuite( + @PathVariable("id") Long id, + @RequestBody Map body, HttpServletRequest req) { + ResponseEntity> res = testSuiteService.update(id, body); + if (res.getStatusCode().is2xxSuccessful() && res.getBody() != null) { + TestSuite suite = (TestSuite) res.getBody().get("suite"); + auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "UPDATE", "TEST_SUITE", suite.getId().toString(), "Updated Test Suite: " + suite.getName())); + } + return res; + } + + @PostMapping("/{id}/run") + public ResponseEntity> runTestSuite( + @PathVariable("id") Long id, + @RequestBody(required = false) Map body, + HttpServletRequest req) { + return testSuiteService.run(id, body, orgId(req)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteTestSuite(@PathVariable("id") Long id, HttpServletRequest req) { + ResponseEntity res = testSuiteService.delete(id); + if (res.getStatusCode().is2xxSuccessful()) { + auditLogRepository.save(new AuditLog(orgId(req), getUserEmail(req), "DELETE", "TEST_SUITE", id.toString(), "Deleted Test Suite ID: " + id)); + } + return res; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/controller/VariableController.java b/master/src/main/java/com/autopilot/localagent_cloud/controller/VariableController.java new file mode 100644 index 0000000..f2e5902 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/controller/VariableController.java @@ -0,0 +1,93 @@ +package com.autopilot.localagent_cloud.controller; + +import com.autopilot.localagent_cloud.model.Variable; +import com.autopilot.localagent_cloud.repository.VariableRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/variables") +public class VariableController { + + // Industry-standard sentinel — used to detect "user did not change the secret value" + private static final String SECRET_MASK = "••••••••"; + + private final VariableRepository variableRepository; + + public VariableController(VariableRepository variableRepository) { + this.variableRepository = variableRepository; + } + + private Long getOrgId(HttpServletRequest request) { + // Use the orgId already extracted and validated by JwtAuthFilter + Object o = request.getAttribute("orgId"); + return o != null ? ((Number) o).longValue() : null; + } + + @GetMapping + public ResponseEntity> getAll( + @RequestParam(required = false) String scope, + @RequestParam(required = false) Long scopeId, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + List vars; + if (scope != null && scopeId != null) { + vars = variableRepository.findByOrgIdAndScopeAndScopeId(orgId, scope.toUpperCase(), scopeId); + } else if (scope != null) { + vars = variableRepository.findByOrgIdAndScope(orgId, scope.toUpperCase()); + } else { + vars = variableRepository.findByOrgId(orgId); + } + // Bug fix: mask secret values using the clean constant (was a corrupted UTF-8 literal before) + vars.forEach(v -> { if (Boolean.TRUE.equals(v.getIsSecret())) v.setValue(SECRET_MASK); }); + return ResponseEntity.ok(vars); + } + + @PostMapping + public ResponseEntity create(@RequestBody Variable variable, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + variable.setOrgId(orgId); + if (variable.getScope() == null) variable.setScope("GLOBAL"); + return ResponseEntity.ok(variableRepository.save(variable)); + } + + @PutMapping("/{id}") + public ResponseEntity update(@PathVariable Long id, + @RequestBody Variable body, + HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + return variableRepository.findById(id) + .filter(v -> orgId.equals(v.getOrgId())) // Tenant isolation check + .map(v -> { + v.setKeyName(body.getKeyName()); + // Bug fix: only update value if it's not the sentinel mask string + // (was broken before because the corrupted UTF-8 literal never matched) + if (body.getValue() != null && !SECRET_MASK.equals(body.getValue())) { + v.setValue(body.getValue()); + } + v.setScope(body.getScope() != null ? body.getScope() : v.getScope()); + v.setScopeId(body.getScopeId()); + v.setIsSecret(body.getIsSecret()); + return ResponseEntity.ok(variableRepository.save(v)); + }).orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id, HttpServletRequest request) { + Long orgId = getOrgId(request); + if (orgId == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return variableRepository.findById(id) + .filter(v -> orgId.equals(v.getOrgId())) // Tenant isolation check + .map(v -> { variableRepository.delete(v); return ResponseEntity.ok().build(); }) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/dto/RunRequest.java b/master/src/main/java/com/autopilot/localagent_cloud/dto/RunRequest.java new file mode 100644 index 0000000..4e84af4 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/dto/RunRequest.java @@ -0,0 +1,8 @@ +package com.autopilot.localagent_cloud.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class RunRequest { + public RunResult result; +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/dto/RunResult.java b/master/src/main/java/com/autopilot/localagent_cloud/dto/RunResult.java new file mode 100644 index 0000000..992a8f8 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/dto/RunResult.java @@ -0,0 +1,28 @@ +package com.autopilot.localagent_cloud.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class RunResult { + public String environmentId; + public String environmentName; + public String projectId; + public String applicationId; + public String applicationName; + public String platformId; + public String platformName; + public Integer browserTypeId; + public String browserTypeName; + public String groupId; + public String groupName; + public String referenceId; + public String serviceUrl; + public String request_handler; + public String iterationval; + public List>> testCase; + + // Mutated status + public Integer result_status; +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/dto/TestCaseIteration.java b/master/src/main/java/com/autopilot/localagent_cloud/dto/TestCaseIteration.java new file mode 100644 index 0000000..8373260 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/dto/TestCaseIteration.java @@ -0,0 +1,13 @@ +package com.autopilot.localagent_cloud.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class TestCaseIteration { + public List testSteps; + + // Mutated status + public Integer executed_status; + public Integer result_status; +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/dto/TestStep.java b/master/src/main/java/com/autopilot/localagent_cloud/dto/TestStep.java new file mode 100644 index 0000000..f03d744 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/dto/TestStep.java @@ -0,0 +1,20 @@ +package com.autopilot.localagent_cloud.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class TestStep { + public String step_result_id; + public String actionName; + public String locatorName; + public String objectDetail; + public String data; + public String screenShot; + public String screenshot_path; + public String stepDesc; + public String errorLog; + + // Mutated status + public Integer executed_status; + public Integer result_status; +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/governance/AuditController.java b/master/src/main/java/com/autopilot/localagent_cloud/governance/AuditController.java new file mode 100644 index 0000000..d885665 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/governance/AuditController.java @@ -0,0 +1,25 @@ +package com.autopilot.localagent_cloud.governance; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; + +/** + * Storage, Logging & Governance Layer + * Handles audit trails, decision logging, and compliance reporting. + */ +@RestController +@RequestMapping("/api/governance") +public class AuditController { + + @GetMapping("/audit-logs") + public ResponseEntity> getAuditLogs(@RequestParam(required = false) String buildId) { + // TODO: Fetch immutable execution logs and manual override decisions + return ResponseEntity.ok(Collections.singletonList("Initial audit log: Build pipeline created")); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/ingestion/TestIngestionController.java b/master/src/main/java/com/autopilot/localagent_cloud/ingestion/TestIngestionController.java new file mode 100644 index 0000000..7199e78 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/ingestion/TestIngestionController.java @@ -0,0 +1,22 @@ +package com.autopilot.localagent_cloud.ingestion; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Test Data Ingestion Layer + * Handles incoming webhooks and test scripts from CI/CD systems. + */ +@RestController +@RequestMapping("/api/ingestion") +public class TestIngestionController { + + @PostMapping("/webhook") + public ResponseEntity receiveWebhook(@RequestBody String payload) { + // TODO: Parse webhook payload from GitHub/Jenkins and enqueue for execution + return ResponseEntity.ok("Webhook received and queued for processing"); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Agent.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Agent.java new file mode 100644 index 0000000..d1db6ac --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Agent.java @@ -0,0 +1,95 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "agents") +public class Agent { + + @Id + private String id; + + @Column(name = "org_id") + private Long orgId; + + @Column(nullable = false) + private String name; + + private String os; + + @Column(name = "agent_version") + private String agentVersion; + + @Column(name = "last_seen_at", nullable = false) + private LocalDateTime lastSeenAt; + + @Column(name = "capabilities_json", columnDefinition = "text") + private String capabilitiesJson; + + @PrePersist + protected void onCreate() { + lastSeenAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + lastSeenAt = LocalDateTime.now(); + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getOs() { + return os; + } + + public void setOs(String os) { + this.os = os; + } + + public String getAgentVersion() { + return agentVersion; + } + + public void setAgentVersion(String agentVersion) { + this.agentVersion = agentVersion; + } + + public LocalDateTime getLastSeenAt() { + return lastSeenAt; + } + + public void setLastSeenAt(LocalDateTime lastSeenAt) { + this.lastSeenAt = lastSeenAt; + } + + public String getCapabilitiesJson() { + return capabilitiesJson; + } + + public void setCapabilitiesJson(String capabilitiesJson) { + this.capabilitiesJson = capabilitiesJson; + } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/AgentGroupMapping.java b/master/src/main/java/com/autopilot/localagent_cloud/model/AgentGroupMapping.java new file mode 100644 index 0000000..ba75c28 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/AgentGroupMapping.java @@ -0,0 +1,33 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "agent_group_mappings") +public class AgentGroupMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "agent_id", nullable = false) + private String agentId; + + @Column(name = "group_id", nullable = false) + private Long groupId; + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getAgentId() { return agentId; } + public void setAgentId(String agentId) { this.agentId = agentId; } + + public Long getGroupId() { return groupId; } + public void setGroupId(Long groupId) { this.groupId = groupId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/AgentToken.java b/master/src/main/java/com/autopilot/localagent_cloud/model/AgentToken.java new file mode 100644 index 0000000..f80c843 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/AgentToken.java @@ -0,0 +1,20 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "agent_tokens") +public class AgentToken { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "org_id", nullable = false) private Long orgId; + @Column(nullable = false, unique = true) private String token; + @Column private String label; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); } + public Long getId() { return id; } public void setId(Long id) { this.id = id; } + public Long getOrgId() { return orgId; } public void setOrgId(Long orgId) { this.orgId = orgId; } + public String getToken() { return token; } public void setToken(String token) { this.token = token; } + public String getLabel() { return label; } public void setLabel(String label) { this.label = label; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/ApiKey.java b/master/src/main/java/com/autopilot/localagent_cloud/model/ApiKey.java new file mode 100644 index 0000000..951944a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/ApiKey.java @@ -0,0 +1,57 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "api_keys") +public class ApiKey { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id", nullable = false) + private Long orgId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String token; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "last_used_at") + private LocalDateTime lastUsedAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/AppUser.java b/master/src/main/java/com/autopilot/localagent_cloud/model/AppUser.java new file mode 100644 index 0000000..a0a5b3a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/AppUser.java @@ -0,0 +1,26 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "app_users") +public class AppUser { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "org_id", nullable = false) private Long orgId; + @Column(nullable = false, unique = true) private String email; + @Column(name = "password_hash", nullable = false) private String passwordHash; + @Column(name = "full_name") private String fullName; + @Column(nullable = false) private String role = "admin"; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @Column(name = "requires_password_change") private boolean requiresPasswordChange = false; + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); } + public Long getId() { return id; } public void setId(Long id) { this.id = id; } + public Long getOrgId() { return orgId; } public void setOrgId(Long orgId) { this.orgId = orgId; } + public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } + public String getPasswordHash() { return passwordHash; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + public String getFullName() { return fullName; } public void setFullName(String fullName) { this.fullName = fullName; } + public String getRole() { return role; } public void setRole(String role) { this.role = role; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public boolean isRequiresPasswordChange() { return requiresPasswordChange; } public void setRequiresPasswordChange(boolean requiresPasswordChange) { this.requiresPasswordChange = requiresPasswordChange; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/AuditLog.java b/master/src/main/java/com/autopilot/localagent_cloud/model/AuditLog.java new file mode 100644 index 0000000..5fe3671 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/AuditLog.java @@ -0,0 +1,80 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "audit_logs") +public class AuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id", nullable = false) + private Long orgId; + + @Column(name = "user_email") + private String userEmail; + + @Column(nullable = false) + private String action; + + @Column(name = "entity_type", nullable = false) + private String entityType; + + @Column(name = "entity_id") + private String entityId; + + @Column(length = 1000) + private String details; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public AuditLog() {} + + public AuditLog(Long orgId, String userEmail, String action, String entityType, String entityId, String details) { + this.orgId = orgId; + this.userEmail = userEmail; + this.action = action; + this.entityType = entityType; + this.entityId = entityId; + this.details = details; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + + public String getUserEmail() { return userEmail; } + public void setUserEmail(String userEmail) { this.userEmail = userEmail; } + + public String getAction() { return action; } + public void setAction(String action) { this.action = action; } + + public String getEntityType() { return entityType; } + public void setEntityType(String entityType) { this.entityType = entityType; } + + public String getEntityId() { return entityId; } + public void setEntityId(String entityId) { this.entityId = entityId; } + + public String getDetails() { return details; } + public void setDetails(String details) { this.details = details; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Dataset.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Dataset.java new file mode 100644 index 0000000..938174d --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Dataset.java @@ -0,0 +1,65 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "datasets") +public class Dataset { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description") + private String description; + + @Column(name = "headers", columnDefinition = "text") + private String headers; + + @Column(name = "rows", columnDefinition = "text") + private String rows; + + @Column(name = "row_count") + private Integer rowCount; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getHeaders() { return headers; } + public void setHeaders(String headers) { this.headers = headers; } + public String getRows() { return rows; } + public void setRows(String rows) { this.rows = rows; } + public Integer getRowCount() { return rowCount; } + public void setRowCount(Integer rowCount) { this.rowCount = rowCount; } + public LocalDateTime getCreatedAt() { return createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/DevicePairing.java b/master/src/main/java/com/autopilot/localagent_cloud/model/DevicePairing.java new file mode 100644 index 0000000..0bd25f1 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/DevicePairing.java @@ -0,0 +1,54 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "device_pairings") +public class DevicePairing { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "pairing_code", nullable = false, unique = true) + private String pairingCode; + + @Column(nullable = false) + private String status = "PENDING"; // PENDING, PAIRED, EXPIRED + + @Column(name = "agent_token") + private String agentToken; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + if (this.expiresAt == null) { + this.expiresAt = this.createdAt.plusMinutes(15); // Valid for 15 minutes + } + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getPairingCode() { return pairingCode; } + public void setPairingCode(String pairingCode) { this.pairingCode = pairingCode; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public String getAgentToken() { return agentToken; } + public void setAgentToken(String agentToken) { this.agentToken = agentToken; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getExpiresAt() { return expiresAt; } + public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Environment.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Environment.java new file mode 100644 index 0000000..1c16ff4 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Environment.java @@ -0,0 +1,38 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "environments") +public class Environment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "description", columnDefinition = "text") + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public LocalDateTime getCreatedAt() { return createdAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Execution.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Execution.java new file mode 100644 index 0000000..9ec91b7 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Execution.java @@ -0,0 +1,124 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +@Entity +@Table(name = "executions") +public class Execution { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "org_execution_id") + private Long orgExecutionId; + + @Column(name = "environment_id") + private Long environmentId; + + @Column(name = "target_group_id") + private Long targetGroupId; + + @Column(name = "browser_type") + private String browserType; + + @Column(name = "browser_version") + private String browserVersion; + + @Column(nullable = false) + private String status; // QUEUED, RUNNING, SUCCESS, FAILED + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "finished_at") + private LocalDateTime finishedAt; + + @Column(name = "environment_json", columnDefinition = "text") + private String environmentJson; + + @Column(name = "scheduler_id") + private Long schedulerId; + + @Column(name = "ai_analysis", columnDefinition = "text") + private String aiAnalysis; + + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(LocalDateTime finishedAt) { + this.finishedAt = finishedAt; + } + + public String getEnvironmentJson() { + return environmentJson; + } + + public void setEnvironmentJson(String environmentJson) { + this.environmentJson = environmentJson; + } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + + public Long getOrgExecutionId() { return orgExecutionId; } + public void setOrgExecutionId(Long orgExecutionId) { this.orgExecutionId = orgExecutionId; } + + public Long getEnvironmentId() { return environmentId; } + public void setEnvironmentId(Long environmentId) { this.environmentId = environmentId; } + + public Long getTargetGroupId() { return targetGroupId; } + public void setTargetGroupId(Long targetGroupId) { this.targetGroupId = targetGroupId; } + + public String getBrowserType() { return browserType; } + public void setBrowserType(String browserType) { this.browserType = browserType; } + + public String getBrowserVersion() { return browserVersion; } + public void setBrowserVersion(String browserVersion) { this.browserVersion = browserVersion; } + + public Long getSchedulerId() { return schedulerId; } + public void setSchedulerId(Long schedulerId) { this.schedulerId = schedulerId; } + + public String getAiAnalysis() { return aiAnalysis; } + public void setAiAnalysis(String aiAnalysis) { this.aiAnalysis = aiAnalysis; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Group.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Group.java new file mode 100644 index 0000000..dbc6292 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Group.java @@ -0,0 +1,71 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +@Entity +@Table(name = "groups") +public class Group { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Job.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Job.java new file mode 100644 index 0000000..3c77f95 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Job.java @@ -0,0 +1,125 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "jobs") +public class Job { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "execution_id") + private Long executionId; + + @Column(name = "agent_id") + private String agentId; + + @Column(name = "target_group_id") + private Long targetGroupId; + + @Column(name = "test_case_id") + private Long testCaseId; + + @Column(name = "browser_type") + private String browserType; + + @Column(name = "browser_version") + private String browserVersion; + + @Column(nullable = false) + private String status; // QUEUED, ASSIGNED, COMPLETED, TIMEOUT + + @Column(name = "lease_expires_at") + private LocalDateTime leaseExpiresAt; + + @Column(name = "payload_json", nullable = false, columnDefinition = "text") + private String payloadJson; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getExecutionId() { + return executionId; + } + + public void setExecutionId(Long executionId) { + this.executionId = executionId; + } + + public String getAgentId() { + return agentId; + } + + public void setAgentId(String agentId) { + this.agentId = agentId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public LocalDateTime getLeaseExpiresAt() { + return leaseExpiresAt; + } + + public void setLeaseExpiresAt(LocalDateTime leaseExpiresAt) { + this.leaseExpiresAt = leaseExpiresAt; + } + + public String getPayloadJson() { + return payloadJson; + } + + public void setPayloadJson(String payloadJson) { + this.payloadJson = payloadJson; + } + + public String getBrowserType() { + return browserType; + } + + public void setBrowserType(String browserType) { + this.browserType = browserType; + } + + public String getBrowserVersion() { + return browserVersion; + } + + public void setBrowserVersion(String browserVersion) { + this.browserVersion = browserVersion; + } + + public Long getTargetGroupId() { + return targetGroupId; + } + + public void setTargetGroupId(Long targetGroupId) { + this.targetGroupId = targetGroupId; + } + + public Long getTestCaseId() { + return testCaseId; + } + + public void setTestCaseId(Long testCaseId) { + this.testCaseId = testCaseId; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Organisation.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Organisation.java new file mode 100644 index 0000000..1a6b25c --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Organisation.java @@ -0,0 +1,24 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "organisations") +public class Organisation { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) private String name; + @Column(nullable = false) private String plan = "trial"; + @Column(unique = true) private String subdomain; + @Column(name = "created_at", nullable = false, updatable = false) private LocalDateTime createdAt; + @PrePersist protected void onCreate() { this.createdAt = LocalDateTime.now(); } + @Column(name = "public_id", unique = true) private String publicId; + // getters/setters for id, name, plan, createdAt, subdomain, publicId + public Long getId() { return id; } public void setId(Long id) { this.id = id; } + public String getName() { return name; } public void setName(String name) { this.name = name; } + public String getPlan() { return plan; } public void setPlan(String plan) { this.plan = plan; } + public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public String getSubdomain() { return subdomain; } public void setSubdomain(String subdomain) { this.subdomain = subdomain; } + public String getPublicId() { return publicId; } public void setPublicId(String publicId) { this.publicId = publicId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Scheduler.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Scheduler.java new file mode 100644 index 0000000..099bfdd --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Scheduler.java @@ -0,0 +1,211 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +@Entity +@Table(name = "schedulers") +public class Scheduler { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "test_suite_name", nullable = false) + private String testSuiteName; + + @Column(name = "execution_type", nullable = false) + private String executionType; + + @Column(name = "cron_expression") + private String cronExpression; + + @Column(name = "browser_type", nullable = false) + private String browserType; + + @Column(nullable = false) + private String status; + + @Column(name = "test_suite_id") + private Long testSuiteId; + + @Column(name = "environment_id") + private Long environmentId; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "target_group_id") + private Long targetGroupId; + + @Column(name = "browser_version") + private String browserVersion; + + // ─── Outlook-style scheduling fields ────────────────────────────────────── + + /** The specific date to run (used for 'once' and as the start date for recurring) */ + @Column(name = "scheduled_date") + private LocalDate scheduledDate; + + /** The time of day to run */ + @Column(name = "scheduled_time") + private LocalTime scheduledTime; + + /** Recurrence pattern: 'once', 'daily', 'weekly', 'monthly' */ + @Column(name = "recurrence_type") + private String recurrenceType; + + /** Comma-separated day abbreviations for weekly recurrence, e.g. "MON,WED,FRI" */ + @Column(name = "recurrence_days") + private String recurrenceDays; + + /** Optional end date for recurring schedules */ + @Column(name = "recurrence_end_date") + private LocalDate recurrenceEndDate; + + @Column(name = "timezone") + private String timezone; + + // ——————————————————————————————————————————————————————————————————————————— + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getTestSuiteName() { + return testSuiteName; + } + + public void setTestSuiteName(String testSuiteName) { + this.testSuiteName = testSuiteName; + } + + public String getExecutionType() { + return executionType; + } + + public void setExecutionType(String executionType) { + this.executionType = executionType; + } + + public String getCronExpression() { + return cronExpression; + } + + public void setCronExpression(String cronExpression) { + this.cronExpression = cronExpression; + } + + public String getBrowserType() { + return browserType; + } + + public void setBrowserType(String browserType) { + this.browserType = browserType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public Long getTestSuiteId() { + return testSuiteId; + } + + public void setTestSuiteId(Long testSuiteId) { + this.testSuiteId = testSuiteId; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDate getScheduledDate() { + return scheduledDate; + } + + public void setScheduledDate(LocalDate scheduledDate) { + this.scheduledDate = scheduledDate; + } + + public LocalTime getScheduledTime() { + return scheduledTime; + } + + public void setScheduledTime(LocalTime scheduledTime) { + this.scheduledTime = scheduledTime; + } + + public String getRecurrenceType() { + return recurrenceType; + } + + public void setRecurrenceType(String recurrenceType) { + this.recurrenceType = recurrenceType; + } + + public String getRecurrenceDays() { + return recurrenceDays; + } + + public void setRecurrenceDays(String recurrenceDays) { + this.recurrenceDays = recurrenceDays; + } + + public LocalDate getRecurrenceEndDate() { + return recurrenceEndDate; + } + + public void setRecurrenceEndDate(LocalDate recurrenceEndDate) { + this.recurrenceEndDate = recurrenceEndDate; + } + + public String getTimezone() { + return timezone; + } + + public void setTimezone(String timezone) { + this.timezone = timezone; + } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + + public Long getEnvironmentId() { return environmentId; } + public void setEnvironmentId(Long environmentId) { this.environmentId = environmentId; } + + public Long getTargetGroupId() { return targetGroupId; } + public void setTargetGroupId(Long targetGroupId) { this.targetGroupId = targetGroupId; } + + public String getBrowserVersion() { return browserVersion; } + public void setBrowserVersion(String browserVersion) { this.browserVersion = browserVersion; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Screenshot.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Screenshot.java new file mode 100644 index 0000000..ac47ffe --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Screenshot.java @@ -0,0 +1,80 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "screenshots") +public class Screenshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "execution_id") + private Long executionId; + + @Column(name = "step_result_id") + private Long stepResultId; + + @Column(name = "file_name", nullable = false) + private String fileName; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "storage_path", nullable = false) + private String storagePath; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getExecutionId() { + return executionId; + } + + public void setExecutionId(Long executionId) { + this.executionId = executionId; + } + + public Long getStepResultId() { + return stepResultId; + } + + public void setStepResultId(Long stepResultId) { + this.stepResultId = stepResultId; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getStoragePath() { + return storagePath; + } + + public void setStoragePath(String storagePath) { + this.storagePath = storagePath; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/StepResult.java b/master/src/main/java/com/autopilot/localagent_cloud/model/StepResult.java new file mode 100644 index 0000000..d6253c3 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/StepResult.java @@ -0,0 +1,113 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "step_results") +public class StepResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "execution_id") + private Long executionId; + + @Column(name = "step_index", nullable = false) + private Integer stepIndex; + + @Column(name = "action_name", nullable = false) + private String actionName; + + @Column(name = "executed_status", nullable = false) + private Integer executedStatus; + + @Column(name = "result_status", nullable = false) + private Integer resultStatus; + + @Column(name = "error_json", columnDefinition = "text") + private String errorJson; + + @Column(name = "actual_value", columnDefinition = "text") + private String actualValue; + + @Column(name = "step_type") + private String stepType = "ACTION"; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getExecutionId() { + return executionId; + } + + public void setExecutionId(Long executionId) { + this.executionId = executionId; + } + + public Integer getStepIndex() { + return stepIndex; + } + + public void setStepIndex(Integer stepIndex) { + this.stepIndex = stepIndex; + } + + public String getActionName() { + return actionName; + } + + public void setActionName(String actionName) { + this.actionName = actionName; + } + + public Integer getExecutedStatus() { + return executedStatus; + } + + public void setExecutedStatus(Integer executedStatus) { + this.executedStatus = executedStatus; + } + + public Integer getResultStatus() { + return resultStatus; + } + + public void setResultStatus(Integer resultStatus) { + this.resultStatus = resultStatus; + } + + public String getErrorJson() { + return errorJson; + } + + public void setErrorJson(String errorJson) { + this.errorJson = errorJson; + } + + public String getActualValue() { + return actualValue; + } + + public void setActualValue(String actualValue) { + this.actualValue = actualValue; + } + + public String getStepType() { + return stepType; + } + + public void setStepType(String stepType) { + this.stepType = stepType; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestCase.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCase.java new file mode 100644 index 0000000..2527ed8 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCase.java @@ -0,0 +1,76 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_cases") +public class TestCase { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private String status = "active"; + + @Column(name = "is_component") + private Boolean isComponent = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + + public Boolean getIsComponent() { return isComponent; } + public void setIsComponent(Boolean isComponent) { this.isComponent = isComponent; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroup.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroup.java new file mode 100644 index 0000000..56598c0 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroup.java @@ -0,0 +1,70 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_case_groups") +public class TestCaseGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(nullable = false) + private String status = "active"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroupMapping.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroupMapping.java new file mode 100644 index 0000000..f69aece --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestCaseGroupMapping.java @@ -0,0 +1,39 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_case_group_mappings") +public class TestCaseGroupMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "test_case_group_id", nullable = false) + private Long testCaseGroupId; + + @Column(name = "test_case_id", nullable = false) + private Long testCaseId; + + @Column(name = "case_order", nullable = false) + private Integer caseOrder = 0; + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getTestCaseGroupId() { return testCaseGroupId; } + public void setTestCaseGroupId(Long testCaseGroupId) { this.testCaseGroupId = testCaseGroupId; } + + public Long getTestCaseId() { return testCaseId; } + public void setTestCaseId(Long testCaseId) { this.testCaseId = testCaseId; } + + public Integer getCaseOrder() { return caseOrder; } + public void setCaseOrder(Integer caseOrder) { this.caseOrder = caseOrder; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestStep.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestStep.java new file mode 100644 index 0000000..0d893b4 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestStep.java @@ -0,0 +1,87 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_steps") +public class TestStep { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "test_case_id", nullable = false) + private Long testCaseId; + + @Column(name = "step_order", nullable = false) + private Integer stepOrder; + + @Column(name = "action_name", nullable = false) + private String actionName; + + @Column(name = "locator_type") + private String locatorType; + + @Column(name = "locator_value") + private String locatorValue; + + @Column(name = "test_data") + private String testData; + + @Column(name = "step_type", nullable = false) + private String stepType = "ACTION"; // ACTION or VERIFY + + @Column(name = "expected_value", columnDefinition = "text") + private String expectedValue; + + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getTestCaseId() { return testCaseId; } + public void setTestCaseId(Long testCaseId) { this.testCaseId = testCaseId; } + + public Integer getStepOrder() { return stepOrder; } + public void setStepOrder(Integer stepOrder) { this.stepOrder = stepOrder; } + + public String getActionName() { return actionName; } + public void setActionName(String actionName) { this.actionName = actionName; } + + public String getLocatorType() { return locatorType; } + public void setLocatorType(String locatorType) { this.locatorType = locatorType; } + + public String getLocatorValue() { return locatorValue; } + public void setLocatorValue(String locatorValue) { this.locatorValue = locatorValue; } + + public String getTestData() { return testData; } + public void setTestData(String testData) { this.testData = testData; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getStepType() { return stepType; } + public void setStepType(String stepType) { this.stepType = stepType; } + + public String getExpectedValue() { return expectedValue; } + public void setExpectedValue(String expectedValue) { this.expectedValue = expectedValue; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuite.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuite.java new file mode 100644 index 0000000..ab75046 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuite.java @@ -0,0 +1,76 @@ +package com.autopilot.localagent_cloud.model; + +import java.time.LocalDateTime; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_suites") +public class TestSuite { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(nullable = false) + private String name; + + private String description; + + @Column(name = "browser_type", nullable = false) + private String browserType = "chrome"; + + @Column(nullable = false) + private String status = "active"; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getBrowserType() { return browserType; } + public void setBrowserType(String browserType) { this.browserType = browserType; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuiteGroupMapping.java b/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuiteGroupMapping.java new file mode 100644 index 0000000..6af6661 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/TestSuiteGroupMapping.java @@ -0,0 +1,39 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "test_suite_group_mappings") +public class TestSuiteGroupMapping { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "test_suite_id", nullable = false) + private Long testSuiteId; + + @Column(name = "test_case_group_id", nullable = false) + private Long testCaseGroupId; + + @Column(name = "group_order", nullable = false) + private Integer groupOrder = 0; + + // Getters and Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getTestSuiteId() { return testSuiteId; } + public void setTestSuiteId(Long testSuiteId) { this.testSuiteId = testSuiteId; } + + public Long getTestCaseGroupId() { return testCaseGroupId; } + public void setTestCaseGroupId(Long testCaseGroupId) { this.testCaseGroupId = testCaseGroupId; } + + public Integer getGroupOrder() { return groupOrder; } + public void setGroupOrder(Integer groupOrder) { this.groupOrder = groupOrder; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/model/Variable.java b/master/src/main/java/com/autopilot/localagent_cloud/model/Variable.java new file mode 100644 index 0000000..3831bc2 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/model/Variable.java @@ -0,0 +1,53 @@ +package com.autopilot.localagent_cloud.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "variables") +public class Variable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "org_id") + private Long orgId; + + @Column(name = "scope", nullable = false) + private String scope = "GLOBAL"; // GLOBAL, SUITE, ENVIRONMENT + + @Column(name = "scope_id") + private Long scopeId; + + @Column(name = "key_name", nullable = false) + private String keyName; + + @Column(name = "value", columnDefinition = "text") + private String value; + + @Column(name = "is_secret") + private Boolean isSecret = false; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { this.createdAt = LocalDateTime.now(); } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Long getOrgId() { return orgId; } + public void setOrgId(Long orgId) { this.orgId = orgId; } + public String getScope() { return scope; } + public void setScope(String scope) { this.scope = scope; } + public Long getScopeId() { return scopeId; } + public void setScopeId(Long scopeId) { this.scopeId = scopeId; } + public String getKeyName() { return keyName; } + public void setKeyName(String keyName) { this.keyName = keyName; } + public String getValue() { return value; } + public void setValue(String value) { this.value = value; } + public Boolean getIsSecret() { return isSecret; } + public void setIsSecret(Boolean isSecret) { this.isSecret = isSecret; } + public LocalDateTime getCreatedAt() { return createdAt; } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentGroupMappingRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentGroupMappingRepository.java new file mode 100644 index 0000000..488feeb --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentGroupMappingRepository.java @@ -0,0 +1,16 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.AgentGroupMapping; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface AgentGroupMappingRepository extends JpaRepository { + List findByGroupId(Long groupId); + List findByAgentId(String agentId); + + @Transactional + void deleteByAgentIdAndGroupId(String agentId, Long groupId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentRepository.java new file mode 100644 index 0000000..888c95e --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentRepository.java @@ -0,0 +1,12 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Agent; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AgentRepository extends JpaRepository { + List findByOrgId(Long orgId); + List findAll(); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentTokenRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentTokenRepository.java new file mode 100644 index 0000000..1a74151 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/AgentTokenRepository.java @@ -0,0 +1,12 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.AgentToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface AgentTokenRepository extends JpaRepository { + Optional findByToken(String token); + java.util.List findByOrgId(Long orgId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/ApiKeyRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/ApiKeyRepository.java new file mode 100644 index 0000000..f755fbb --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/ApiKeyRepository.java @@ -0,0 +1,14 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.ApiKey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface ApiKeyRepository extends JpaRepository { + List findByOrgId(Long orgId); + Optional findByToken(String token); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/AppUserRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/AppUserRepository.java new file mode 100644 index 0000000..79634bd --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/AppUserRepository.java @@ -0,0 +1,13 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.AppUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import java.util.Optional; + +@Repository +public interface AppUserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); + java.util.List findByOrgId(Long orgId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/AuditLogRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/AuditLogRepository.java new file mode 100644 index 0000000..d6ed443 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/AuditLogRepository.java @@ -0,0 +1,9 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.AuditLog; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface AuditLogRepository extends JpaRepository { + List findByOrgIdOrderByCreatedAtDesc(Long orgId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/DatasetRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/DatasetRepository.java new file mode 100644 index 0000000..5958849 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/DatasetRepository.java @@ -0,0 +1,9 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Dataset; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface DatasetRepository extends JpaRepository { + List findByOrgId(Long orgId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/DevicePairingRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/DevicePairingRepository.java new file mode 100644 index 0000000..5bebd7c --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/DevicePairingRepository.java @@ -0,0 +1,12 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.DevicePairing; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface DevicePairingRepository extends JpaRepository { + Optional findByPairingCode(String pairingCode); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/EnvironmentRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/EnvironmentRepository.java new file mode 100644 index 0000000..a53913a --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/EnvironmentRepository.java @@ -0,0 +1,9 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Environment; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface EnvironmentRepository extends JpaRepository { + List findByOrgId(Long orgId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/ExecutionRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/ExecutionRepository.java new file mode 100644 index 0000000..dbb6703 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/ExecutionRepository.java @@ -0,0 +1,15 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Execution; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ExecutionRepository extends JpaRepository { + List findAllByOrderByIdDesc(); + long countByOrgId(Long orgId); + List findByStatusAndCreatedAtBefore(String status, java.time.LocalDateTime cutoff); + Optional findFirstBySchedulerIdOrderByIdDesc(Long schedulerId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/GroupRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/GroupRepository.java new file mode 100644 index 0000000..5bf4b8d --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/GroupRepository.java @@ -0,0 +1,9 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Group; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface GroupRepository extends JpaRepository { +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/JobRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/JobRepository.java new file mode 100644 index 0000000..54920cf --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/JobRepository.java @@ -0,0 +1,21 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Job; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface JobRepository extends JpaRepository { + + @Query("SELECT j FROM Job j WHERE j.status = 'QUEUED' " + + "AND (j.agentId = :agentId OR j.agentId IS NULL) " + + "AND (j.targetGroupId IS NULL OR j.targetGroupId IN :groupIDs) " + + "ORDER BY j.id ASC") + List findNextAvailableJobs(@Param("agentId") String agentId, @Param("groupIDs") List groupIDs, Pageable pageable); + + List findByExecutionId(Long executionId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/OrganisationRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/OrganisationRepository.java new file mode 100644 index 0000000..2c01d6f --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/OrganisationRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Organisation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OrganisationRepository extends JpaRepository { + Organisation findBySubdomain(String subdomain); + Organisation findByPublicId(String publicId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/SchedulerRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/SchedulerRepository.java new file mode 100644 index 0000000..5f82526 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/SchedulerRepository.java @@ -0,0 +1,9 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Scheduler; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SchedulerRepository extends JpaRepository { +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/ScreenshotRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/ScreenshotRepository.java new file mode 100644 index 0000000..be42c63 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/ScreenshotRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Screenshot; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ScreenshotRepository extends JpaRepository { + List findByExecutionId(Long executionId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/StepResultRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/StepResultRepository.java new file mode 100644 index 0000000..94d5861 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/StepResultRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.StepResult; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface StepResultRepository extends JpaRepository { + List findByExecutionId(Long executionId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupMappingRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupMappingRepository.java new file mode 100644 index 0000000..554d6c7 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupMappingRepository.java @@ -0,0 +1,15 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestCaseGroupMapping; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface TestCaseGroupMappingRepository extends JpaRepository { + List findByTestCaseGroupIdOrderByCaseOrder(Long testCaseGroupId); + + @Transactional + void deleteByTestCaseGroupId(Long testCaseGroupId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupRepository.java new file mode 100644 index 0000000..91f3dd2 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseGroupRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestCaseGroup; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestCaseGroupRepository extends JpaRepository { + List findAllByOrderByIdDesc(); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseRepository.java new file mode 100644 index 0000000..b222b7c --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestCaseRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestCase; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestCaseRepository extends JpaRepository { + List findAllByOrderByIdDesc(); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestStepRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestStepRepository.java new file mode 100644 index 0000000..78a6878 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestStepRepository.java @@ -0,0 +1,15 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestStep; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface TestStepRepository extends JpaRepository { + List findByTestCaseIdOrderByStepOrder(Long testCaseId); + + @Transactional + void deleteByTestCaseId(Long testCaseId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteGroupMappingRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteGroupMappingRepository.java new file mode 100644 index 0000000..c16b46b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteGroupMappingRepository.java @@ -0,0 +1,15 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestSuiteGroupMapping; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Repository +public interface TestSuiteGroupMappingRepository extends JpaRepository { + List findByTestSuiteIdOrderByGroupOrder(Long testSuiteId); + + @Transactional + void deleteByTestSuiteId(Long testSuiteId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteRepository.java new file mode 100644 index 0000000..d74c728 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/TestSuiteRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.TestSuite; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TestSuiteRepository extends JpaRepository { + List findAllByOrderByIdDesc(); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/repository/VariableRepository.java b/master/src/main/java/com/autopilot/localagent_cloud/repository/VariableRepository.java new file mode 100644 index 0000000..46c3db4 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/repository/VariableRepository.java @@ -0,0 +1,11 @@ +package com.autopilot.localagent_cloud.repository; + +import com.autopilot.localagent_cloud.model.Variable; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface VariableRepository extends JpaRepository { + List findByOrgId(Long orgId); + List findByOrgIdAndScope(Long orgId, String scope); + List findByOrgIdAndScopeAndScopeId(Long orgId, String scope, Long scopeId); +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/AgentService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/AgentService.java new file mode 100644 index 0000000..44c33ba --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/AgentService.java @@ -0,0 +1,508 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Agent; +import com.autopilot.localagent_cloud.model.AgentToken; +import com.autopilot.localagent_cloud.model.Execution; +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.model.Screenshot; +import com.autopilot.localagent_cloud.model.StepResult; +import com.autopilot.localagent_cloud.model.TestCaseGroupMapping; +import com.autopilot.localagent_cloud.model.TestStep; +import com.autopilot.localagent_cloud.model.TestSuite; +import com.autopilot.localagent_cloud.model.TestSuiteGroupMapping; +import com.autopilot.localagent_cloud.repository.AgentRepository; +import com.autopilot.localagent_cloud.repository.AgentTokenRepository; +import com.autopilot.localagent_cloud.repository.ExecutionRepository; +import com.autopilot.localagent_cloud.repository.SchedulerRepository; +import com.autopilot.localagent_cloud.repository.ScreenshotRepository; +import com.autopilot.localagent_cloud.repository.StepResultRepository; +import com.autopilot.localagent_cloud.repository.TestCaseGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.TestStepRepository; +import com.autopilot.localagent_cloud.repository.TestSuiteGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.TestSuiteRepository; +import com.autopilot.localagent_cloud.repository.VariableRepository; +import com.autopilot.localagent_cloud.repository.OrganisationRepository; +import com.autopilot.localagent_cloud.repository.AgentGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.JobRepository; +import com.autopilot.localagent_cloud.model.Organisation; +import com.autopilot.localagent_cloud.model.Variable; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class AgentService { + + private final AgentRepository agentRepository; + private final AgentTokenRepository agentTokenRepository; + private final SchedulerRepository schedulerRepository; + private final ExecutionRepository executionRepository; + private final TestSuiteRepository testSuiteRepository; + private final TestSuiteGroupMappingRepository testSuiteGroupMappingRepository; + private final TestCaseGroupMappingRepository testCaseGroupMappingRepository; + private final TestStepRepository testStepRepository; + private final ScreenshotRepository screenshotRepository; + private final StepResultRepository stepResultRepository; + private final OrganisationRepository organisationRepository; + private final VariableRepository variableRepository; + private final AgentGroupMappingRepository agentGroupMappingRepository; + private final JobRepository jobRepository; + private final ObjectMapper objectMapper; + private final S3Service s3Service; + + public AgentService(AgentRepository agentRepository, + AgentTokenRepository agentTokenRepository, + SchedulerRepository schedulerRepository, + ExecutionRepository executionRepository, + TestSuiteRepository testSuiteRepository, + TestSuiteGroupMappingRepository testSuiteGroupMappingRepository, + TestCaseGroupMappingRepository testCaseGroupMappingRepository, + TestStepRepository testStepRepository, + ScreenshotRepository screenshotRepository, + StepResultRepository stepResultRepository, + OrganisationRepository organisationRepository, + VariableRepository variableRepository, + AgentGroupMappingRepository agentGroupMappingRepository, + JobRepository jobRepository, + ObjectMapper objectMapper, + S3Service s3Service) { + this.agentRepository = agentRepository; + this.agentTokenRepository = agentTokenRepository; + this.schedulerRepository = schedulerRepository; + this.executionRepository = executionRepository; + this.testSuiteRepository = testSuiteRepository; + this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository; + this.testCaseGroupMappingRepository = testCaseGroupMappingRepository; + this.testStepRepository = testStepRepository; + this.screenshotRepository = screenshotRepository; + this.stepResultRepository = stepResultRepository; + this.organisationRepository = organisationRepository; + this.variableRepository = variableRepository; + this.agentGroupMappingRepository = agentGroupMappingRepository; + this.jobRepository = jobRepository; + this.objectMapper = objectMapper; + this.s3Service = s3Service; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? agentRepository.findAll().stream().filter(a -> orgId.equals(a.getOrgId())).toList() + : agentRepository.findAll(); + return ResponseEntity.ok(list); + } + + @Transactional + public ResponseEntity> register(Map body) { + String agentId = (String) body.get("id"); + String agentTokenStr = (String) body.get("agentToken"); + + if (agentId == null || agentId.isBlank()) { + return ResponseEntity.badRequest().body(Map.of("error", "Missing agent id")); + } + if (agentTokenStr == null || agentTokenStr.isBlank()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Missing agentToken")); + } + + AgentToken token = agentTokenRepository.findByToken(agentTokenStr).orElse(null); + if (token == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of("error", "Invalid agentToken")); + } + + Agent agent = agentRepository.findById(agentId).orElse(new Agent()); + agent.setId(agentId); + agent.setOrgId(token.getOrgId()); + agent.setName((String) body.getOrDefault("name", agentId)); + agent.setOs((String) body.get("os")); + agent.setAgentVersion((String) body.get("agentVersion")); + agent.setCapabilitiesJson((String) body.get("capabilitiesJson")); + agent.setLastSeenAt(java.time.LocalDateTime.now()); + + agentRepository.save(agent); + + String orgName = "Unknown Organisation"; + if (token.getOrgId() != null) { + Organisation org = organisationRepository.findById(token.getOrgId()).orElse(null); + if (org != null) { + orgName = org.getName(); + } + } + + Map response = new HashMap<>(); + response.put("status", "success"); + response.put("orgName", orgName); + response.put("tokenLabel", token.getLabel()); + return ResponseEntity.ok(response); + } + + @Transactional + public ResponseEntity heartbeat(String id) { + agentRepository.findById(id).ifPresent(agent -> { + agent.setLastSeenAt(java.time.LocalDateTime.now()); + agentRepository.save(agent); + }); + return ResponseEntity.ok().build(); + } + + @Transactional + @SuppressWarnings("unchecked") + public ResponseEntity> getNextJob(String agentId) { + Agent agent = agentRepository.findById(agentId).orElse(null); + if (agent == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + agent.setLastSeenAt(java.time.LocalDateTime.now()); + agentRepository.save(agent); + + Long agentOrgId = agent.getOrgId(); + + List agentGroupIds = agentGroupMappingRepository.findByAgentId(agentId).stream() + .map(com.autopilot.localagent_cloud.model.AgentGroupMapping::getGroupId) + .toList(); + List groupIDsForQuery = agentGroupIds.isEmpty() ? List.of(-1L) : agentGroupIds; + + String agentBrowserVersion = ""; + try { + if (agent.getCapabilitiesJson() != null) { + Map caps = objectMapper.readValue(agent.getCapabilitiesJson(), Map.class); + if (caps.containsKey("browserVersion")) { + agentBrowserVersion = (String) caps.get("browserVersion"); + } + } + } catch (Exception e) {} + final String finalAgentBrowserVer = agentBrowserVersion; + + // Phase 1: Try to pick up an existing QUEUED job + List candidateJobs = jobRepository.findNextAvailableJobs(agentId, groupIDsForQuery, PageRequest.of(0, 50)); + com.autopilot.localagent_cloud.model.Job selectedJob = candidateJobs.stream().findFirst().orElse(null); + + if (selectedJob != null) { + selectedJob.setStatus("ASSIGNED"); + selectedJob.setAgentId(agentId); + selectedJob.setLeaseExpiresAt(java.time.LocalDateTime.now().plusMinutes(10)); + jobRepository.save(selectedJob); + + try { + Map runRequest = objectMapper.readValue(selectedJob.getPayloadJson(), Map.class); + runRequest.put("jobId", selectedJob.getId()); // Inject jobId for the agent + + Map jobDto = new HashMap<>(); + jobDto.put("executionId", selectedJob.getExecutionId()); + jobDto.put("jobId", selectedJob.getId()); + jobDto.put("payloadJson", objectMapper.writeValueAsString(runRequest)); + return ResponseEntity.ok(jobDto); + } catch (Exception e) { + e.printStackTrace(); + return ResponseEntity.internalServerError().build(); + } + } + + // Phase 2: Unwrap a Scheduler into multiple QUEUED Jobs + List activeJobs = schedulerRepository.findAll().stream() + .filter(s -> "now".equals(s.getExecutionType()) && "active".equals(s.getStatus())) + .filter(s -> agentOrgId == null || agentOrgId.equals(s.getOrgId())) + .toList(); + + if (activeJobs.isEmpty()) { + return ResponseEntity.noContent().build(); + } + + Scheduler job = activeJobs.get(0); + job.setStatus("processing"); + schedulerRepository.save(job); + + Execution execution = new Execution(); + execution.setOrgId(agentOrgId); + + long count = agentOrgId != null ? executionRepository.countByOrgId(agentOrgId) : 0; + execution.setOrgExecutionId(count + 1); + execution.setEnvironmentId(job.getEnvironmentId()); + execution.setTargetGroupId(job.getTargetGroupId()); + execution.setBrowserType(job.getBrowserType()); + execution.setBrowserVersion(job.getBrowserVersion()); + execution.setSchedulerId(job.getId()); + + execution.setEnvironmentJson("{\"referenceId\":\"" + job.getTestSuiteName() + "\",\"browserTypeName\":\"" + job.getBrowserType() + "\"}"); + execution.setStatus("running"); + execution.setCreatedAt(java.time.LocalDateTime.now()); + execution = executionRepository.save(execution); + + // Resolve Variables: GLOBAL and ENVIRONMENT + Map executionVariables = new HashMap<>(); + if (agentOrgId != null) { + List globalVars = variableRepository.findByOrgIdAndScope(agentOrgId, "GLOBAL"); + for (Variable v : globalVars) { + executionVariables.put(v.getKeyName(), v.getValue()); + } + + if (job.getEnvironmentId() != null) { + List envVars = variableRepository.findByOrgIdAndScopeAndScopeId(agentOrgId, "ENVIRONMENT", job.getEnvironmentId()); + for (Variable v : envVars) { + executionVariables.put(v.getKeyName(), v.getValue()); // overrides global + } + } + } + + if (job.getTestSuiteId() != null) { + List groupMappings = testSuiteGroupMappingRepository.findByTestSuiteIdOrderByGroupOrder(job.getTestSuiteId()); + for (TestSuiteGroupMapping gm : groupMappings) { + List caseMappings = testCaseGroupMappingRepository.findByTestCaseGroupIdOrderByCaseOrder(gm.getTestCaseGroupId()); + for (TestCaseGroupMapping cm : caseMappings) { + List rawSteps = testStepRepository.findByTestCaseIdOrderByStepOrder(cm.getTestCaseId()); + List> processedSteps = new ArrayList<>(); + for (TestStep s : rawSteps) { + if ("CALL_COMPONENT".equals(s.getActionName())) { + try { + Long compId = Long.valueOf(s.getLocatorValue()); + List compSteps = testStepRepository.findByTestCaseIdOrderByStepOrder(compId); + for (TestStep cs : compSteps) { + processedSteps.add(stepToMapAndReplace(cs, executionVariables)); + } + } catch (Exception e) {} + } else { + processedSteps.add(stepToMapAndReplace(s, executionVariables)); + } + } + + Map iter = new HashMap<>(); + iter.put("testCaseId", cm.getTestCaseId()); + iter.put("steps", processedSteps); + + Map runRequest = new HashMap<>(); + runRequest.put("executionId", execution.getId()); + runRequest.put("browserType", job.getBrowserType()); + runRequest.put("iterations", List.of(iter)); // ONLY THIS TEST CASE! + runRequest.put("variables", executionVariables); + + try { + String payloadJson = objectMapper.writeValueAsString(runRequest); + com.autopilot.localagent_cloud.model.Job newJob = new com.autopilot.localagent_cloud.model.Job(); + newJob.setExecutionId(execution.getId()); + newJob.setTestCaseId(cm.getTestCaseId()); + newJob.setTargetGroupId(job.getTargetGroupId()); + newJob.setBrowserType(job.getBrowserType()); + newJob.setBrowserVersion(job.getBrowserVersion()); + newJob.setStatus("QUEUED"); + newJob.setPayloadJson(payloadJson); + jobRepository.save(newJob); + } catch (Exception e) {} + } + } + } + + // Bug fix: replaced unbounded recursion with a simple retry — pick up one of the jobs just created. + // The recursive approach had no depth limit and could cause a StackOverflowError + // if job creation failed unexpectedly. + List retryJobs = jobRepository.findNextAvailableJobs( + agentId, groupIDsForQuery, PageRequest.of(0, 50)); + com.autopilot.localagent_cloud.model.Job retryJob = retryJobs.stream().filter(j -> { + if (j.getBrowserVersion() == null || j.getBrowserVersion().isBlank()) return true; + if (finalAgentBrowserVer.isBlank()) return false; + String reqMajor = j.getBrowserVersion().split("\\.")[0]; + String agentMajor = finalAgentBrowserVer.split("\\.")[0]; + return reqMajor.equals(agentMajor); + }).findFirst().orElse(null); + + if (retryJob != null) { + retryJob.setStatus("ASSIGNED"); + retryJob.setAgentId(agentId); + retryJob.setLeaseExpiresAt(java.time.LocalDateTime.now().plusMinutes(10)); + jobRepository.save(retryJob); + try { + Map runRequest = objectMapper.readValue(retryJob.getPayloadJson(), Map.class); + runRequest.put("jobId", retryJob.getId()); + Map jobDto = new HashMap<>(); + jobDto.put("executionId", retryJob.getExecutionId()); + jobDto.put("jobId", retryJob.getId()); + jobDto.put("payloadJson", objectMapper.writeValueAsString(runRequest)); + return ResponseEntity.ok(jobDto); + } catch (Exception e) { + return ResponseEntity.internalServerError().build(); + } + } + return ResponseEntity.noContent().build(); + } + + @Transactional + @SuppressWarnings("unchecked") + public ResponseEntity postResults(Long executionId, Map result) { + try { + Map runResult = result; + if (result.containsKey("result")) { + runResult = (Map) result.get("result"); + } + + // Correctly parse the overall result_status sent by the Java Agent + String finalStatus = "completed"; + if (runResult.containsKey("result_status")) { + Object rs = runResult.get("result_status"); + if (rs instanceof Number && ((Number) rs).intValue() == 0) { + finalStatus = "failed"; + } + } else if (result.containsKey("status")) { + finalStatus = (String) result.get("status"); + } + + final String statusToSave = finalStatus; + + Long jobId = null; + if (result.containsKey("jobId")) { + jobId = ((Number) result.get("jobId")).longValue(); + } + + if (jobId != null) { + jobRepository.findById(jobId).ifPresent(job -> { + job.setStatus(statusToSave); + jobRepository.save(job); + }); + + // Check if all jobs are completed + List allJobs = jobRepository.findByExecutionId(executionId); + boolean allDone = allJobs.stream().allMatch(j -> "completed".equals(j.getStatus()) || "failed".equals(j.getStatus())); + boolean anyFailed = allJobs.stream().anyMatch(j -> "failed".equals(j.getStatus())); + + if (allDone && !allJobs.isEmpty()) { + executionRepository.findById(executionId).ifPresent(exec -> { + exec.setStatus(anyFailed ? "failed" : "completed"); + exec.setFinishedAt(java.time.LocalDateTime.now()); + executionRepository.save(exec); + }); + } + } else { + // Fallback if jobId is not provided + executionRepository.findById(executionId).ifPresent(exec -> { + exec.setStatus(statusToSave); + exec.setFinishedAt(java.time.LocalDateTime.now()); + executionRepository.save(exec); + }); + } + + java.util.List> testCaseList = (java.util.List>) runResult.get("testCase"); + if (testCaseList != null && !testCaseList.isEmpty()) { + Map firstIterationMap = testCaseList.get(0); + java.util.List> iterations = (java.util.List>) firstIterationMap.get("iteration1"); + if (iterations != null && !iterations.isEmpty()) { + java.util.List> testSteps = (java.util.List>) iterations.get(0).get("testSteps"); + if (testSteps != null) { + for (int i = 0; i < testSteps.size(); i++) { + Map stepData = testSteps.get(i); + + StepResult sr = new StepResult(); + sr.setExecutionId(executionId); + sr.setStepIndex(i + 1); + sr.setActionName((String) stepData.getOrDefault("actionName", "unknown")); + sr.setStepType((String) stepData.getOrDefault("stepType", "ACTION")); + + Object execStatus = stepData.get("executed_status"); + sr.setExecutedStatus(execStatus != null ? (Integer) execStatus : 0); + + Object resStatus = stepData.get("result_status"); + sr.setResultStatus(resStatus != null ? (Integer) resStatus : 0); + + sr.setErrorJson((String) stepData.getOrDefault("errorLog", "")); + sr.setActualValue((String) stepData.get("actualValue")); + sr = stepResultRepository.save(sr); + + String base64 = (String) stepData.get("screenshotBase64"); + if (base64 != null && !base64.isEmpty()) { + byte[] imageBytes = java.util.Base64.getDecoder().decode(base64); + String fileName = "exec_" + executionId + "_step_" + sr.getId() + "_" + System.currentTimeMillis() + ".png"; + + String s3Url = s3Service.uploadImage(fileName, imageBytes); + + Screenshot sc = new Screenshot(); + sc.setExecutionId(executionId); + sc.setStepResultId(sr.getId()); + sc.setFileName(fileName); + sc.setContentType("image/png"); + sc.setStoragePath(s3Url); + screenshotRepository.save(sc); + } + } + } + } + } + } catch (Exception ex) { + // Bug fix: was silently swallowing all exceptions and returning 200 OK even when data was lost. + // Now logs the error properly and returns 500 so the agent knows it must retry. + org.slf4j.LoggerFactory.getLogger(AgentService.class) + .error("Critical error processing results for executionId={}: {}", executionId, ex.getMessage(), ex); + return ResponseEntity.status(org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + return ResponseEntity.ok().build(); + } + + @Transactional + public ResponseEntity stopExecution(Long executionId) { + executionRepository.findById(executionId).ifPresent(exec -> { + if ("running".equals(exec.getStatus()) || "queued".equals(exec.getStatus())) { + exec.setStatus("aborted"); + exec.setFinishedAt(java.time.LocalDateTime.now()); + executionRepository.save(exec); + } + }); + return ResponseEntity.ok().build(); + } + + @Transactional + public ResponseEntity rerunExecution(Long executionId) { + executionRepository.findById(executionId).ifPresent(exec -> { + try { + com.fasterxml.jackson.databind.JsonNode env = objectMapper.readTree(exec.getEnvironmentJson()); + String suiteName = env.path("referenceId").asText(); + String browser = env.path("browserTypeName").asText("chrome"); + + TestSuite suite = testSuiteRepository.findAll().stream() + .filter(s -> s.getName().equals(suiteName)) + .findFirst().orElse(null); + + Scheduler scheduler = new Scheduler(); + scheduler.setTestSuiteName(suiteName); + if (suite != null) { + scheduler.setTestSuiteId(suite.getId()); + } + scheduler.setExecutionType("now"); + scheduler.setBrowserType(browser); + scheduler.setStatus("active"); + scheduler.setOrgId(exec.getOrgId()); + schedulerRepository.save(scheduler); + } catch (Exception e) { + e.printStackTrace(); + } + }); + return ResponseEntity.ok().build(); + } + + private Map stepToMapAndReplace(TestStep step, Map vars) { + Map map = new HashMap<>(); + map.put("id", step.getId()); + map.put("stepOrder", step.getStepOrder()); + map.put("actionName", step.getActionName()); + map.put("stepType", step.getStepType()); + map.put("locatorType", step.getLocatorType()); + map.put("locatorValue", replaceVars(step.getLocatorValue(), vars)); + map.put("testData", replaceVars(step.getTestData(), vars)); + map.put("expectedValue", replaceVars(step.getExpectedValue(), vars)); + map.put("description", step.getDescription()); + return map; + } + + private String replaceVars(String text, Map vars) { + if (text == null || vars == null || vars.isEmpty()) return text; + String result = text; + for (Map.Entry entry : vars.entrySet()) { + if (entry.getValue() != null) { + result = result.replace("{{" + entry.getKey() + "}}", entry.getValue()); + } + } + return result; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/AnalyticsService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/AnalyticsService.java new file mode 100644 index 0000000..a4c1c78 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/AnalyticsService.java @@ -0,0 +1,393 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Agent; +import com.autopilot.localagent_cloud.model.Execution; +import com.autopilot.localagent_cloud.model.Job; +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.model.StepResult; +import com.autopilot.localagent_cloud.repository.AgentRepository; +import com.autopilot.localagent_cloud.repository.ExecutionRepository; +import com.autopilot.localagent_cloud.repository.JobRepository; +import com.autopilot.localagent_cloud.repository.SchedulerRepository; +import com.autopilot.localagent_cloud.repository.StepResultRepository; +import com.autopilot.localagent_cloud.repository.TestSuiteRepository; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class AnalyticsService { + + private final ExecutionRepository executionRepository; + private final SchedulerRepository schedulerRepository; + private final StepResultRepository stepResultRepository; + private final AgentRepository agentRepository; + private final JobRepository jobRepository; + private final TestSuiteRepository testSuiteRepository; + + public AnalyticsService(ExecutionRepository executionRepository, + SchedulerRepository schedulerRepository, + StepResultRepository stepResultRepository, + AgentRepository agentRepository, + JobRepository jobRepository, + TestSuiteRepository testSuiteRepository) { + this.executionRepository = executionRepository; + this.schedulerRepository = schedulerRepository; + this.stepResultRepository = stepResultRepository; + this.agentRepository = agentRepository; + this.jobRepository = jobRepository; + this.testSuiteRepository = testSuiteRepository; + } + + /** + * Calculates the "Top Flaky Test Suites" for an org. + * + * Flakiness Score is defined as: + * (number of runs that were NOT all-pass AND not-all-fail) / total runs * 100 + * A suite must have at least 3 runs to be considered. + * + * Returns top 5 flaky suites sorted by flakiness score descending. + */ + public List> getFlakySuites(Long orgId, int limit) { + // 1. Get all executions for the org + List allExecs = executionRepository.findAllByOrderByIdDesc() + .stream() + .filter(e -> orgId == null || orgId.equals(e.getOrgId())) + .filter(e -> e.getSchedulerId() != null) + .collect(Collectors.toList()); + + // 2. Build a map of schedulerId -> Scheduler (for suite name) + Map schedulerNames = new HashMap<>(); + Map schedulerSuiteIds = new HashMap<>(); + schedulerRepository.findAll().forEach(s -> { + schedulerNames.put(s.getId(), s.getTestSuiteName()); + schedulerSuiteIds.put(s.getId(), s.getTestSuiteId()); + }); + + Set existingSuiteIds = testSuiteRepository.findAll().stream().map(s -> s.getId()).collect(Collectors.toSet()); + + // 3. Group executions by testSuiteName via schedulerId + Map> bySuite = allExecs.stream() + .filter(e -> { + Long suiteId = schedulerSuiteIds.get(e.getSchedulerId()); + return suiteId != null && existingSuiteIds.contains(suiteId); + }) + .collect(Collectors.groupingBy(e -> { + String name = schedulerNames.get(e.getSchedulerId()); + return name != null ? name : "Suite #" + e.getSchedulerId(); + })); + + // 4. For each suite, calculate flakiness + List> result = new ArrayList<>(); + for (Map.Entry> entry : bySuite.entrySet()) { + String suiteName = entry.getKey(); + List runs = entry.getValue(); + + // Need at least 2 runs to determine flakiness + if (runs.size() < 2) continue; + + int totalRuns = runs.size(); + int passedRuns = 0; + int failedRuns = 0; + int partialRuns = 0; // Mixed pass/fail steps = flaky + + for (Execution exec : runs) { + String status = exec.getStatus(); + if (status == null) continue; + if (status.equalsIgnoreCase("success") || status.equalsIgnoreCase("completed")) { + passedRuns++; + } else if (status.equalsIgnoreCase("failed")) { + failedRuns++; + } + } + + // Flakiness: suite that doesn't consistently pass or fail + // High flakiness = many alternating results + int notConsistent = totalRuns - Math.max(passedRuns, failedRuns); + // Score from 0-100 + double flakinessScore = totalRuns > 0 ? ((double) Math.min(passedRuns, failedRuns) / totalRuns) * 100.0 : 0; + + if (flakinessScore == 0 && (passedRuns == 0 || failedRuns == 0)) { + // Consistent suite - not flaky + continue; + } + + Map suiteData = new LinkedHashMap<>(); + suiteData.put("suiteName", suiteName); + suiteData.put("totalRuns", totalRuns); + suiteData.put("passedRuns", passedRuns); + suiteData.put("failedRuns", failedRuns); + suiteData.put("flakinessScore", Math.round(flakinessScore * 10.0) / 10.0); + suiteData.put("trend", failedRuns > passedRuns ? "deteriorating" : "improving"); + result.add(suiteData); + } + + // Sort by flakiness score descending, return top N + result.sort((a, b) -> Double.compare( + (double) b.get("flakinessScore"), + (double) a.get("flakinessScore") + )); + + return result.stream().limit(limit).collect(Collectors.toList()); + } + + /** + * Returns per-suite performance stats for the leaderboard: + * suiteName, totalRuns, passedRuns, failedRuns, successRate, avgDurationSecs, lastRunAt + * Sorted by successRate descending (best performers first). + */ + public List> getSuitePerformance(Long orgId, int limit) { + // Get all terminal executions (not QUEUED/RUNNING) linked to a scheduler + List allExecs = executionRepository.findAllByOrderByIdDesc() + .stream() + .filter(e -> orgId == null || orgId.equals(e.getOrgId())) + .filter(e -> e.getSchedulerId() != null) + .filter(e -> { + String s = e.getStatus(); + return s != null && !s.equalsIgnoreCase("queued") && !s.equalsIgnoreCase("running"); + }) + .collect(Collectors.toList()); + + // Build scheduler name lookup + Map schedulerNames = new HashMap<>(); + Map schedulerSuiteIds = new HashMap<>(); + schedulerRepository.findAll().forEach(s -> { + schedulerNames.put(s.getId(), s.getTestSuiteName()); + schedulerSuiteIds.put(s.getId(), s.getTestSuiteId()); + }); + + Set existingSuiteIds = testSuiteRepository.findAll().stream().map(s -> s.getId()).collect(Collectors.toSet()); + + // Group by suite name + Map> bySuite = allExecs.stream() + .filter(e -> { + Long suiteId = schedulerSuiteIds.get(e.getSchedulerId()); + return suiteId != null && existingSuiteIds.contains(suiteId); + }) + .collect(Collectors.groupingBy(e -> { + String name = schedulerNames.get(e.getSchedulerId()); + return name != null ? name : "Suite #" + e.getSchedulerId(); + })); + + List> result = new ArrayList<>(); + for (Map.Entry> entry : bySuite.entrySet()) { + String suiteName = entry.getKey(); + List runs = entry.getValue(); + int totalRuns = runs.size(); + + long passedRuns = runs.stream().filter(e -> + e.getStatus().equalsIgnoreCase("success") || e.getStatus().equalsIgnoreCase("completed") + ).count(); + long failedRuns = runs.stream().filter(e -> e.getStatus().equalsIgnoreCase("failed")).count(); + + double successRate = totalRuns > 0 ? (double) passedRuns / totalRuns * 100.0 : 0.0; + + // Average duration in seconds (only for executions that have both timestamps) + OptionalDouble avgDuration = runs.stream() + .filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null) + .mapToLong(e -> java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds()) + .average(); + + // Most recent run + Optional lastRun = runs.stream() + .map(Execution::getCreatedAt) + .filter(Objects::nonNull) + .max(Comparator.naturalOrder()); + + Map data = new LinkedHashMap<>(); + data.put("suiteName", suiteName); + data.put("totalRuns", totalRuns); + data.put("passedRuns", passedRuns); + data.put("failedRuns", failedRuns); + data.put("successRate", Math.round(successRate * 10.0) / 10.0); + data.put("avgDurationSecs", avgDuration.isPresent() ? Math.round(avgDuration.getAsDouble()) : null); + data.put("lastRunAt", lastRun.orElse(null)); + result.add(data); + } + + // Sort by successRate descending, then by totalRuns descending as tiebreaker + result.sort((a, b) -> { + int cmp = Double.compare((double) b.get("successRate"), (double) a.get("successRate")); + if (cmp != 0) return cmp; + return Integer.compare((int) b.get("totalRuns"), (int) a.get("totalRuns")); + }); + + return result.stream().limit(limit).collect(Collectors.toList()); + } + + /** + * Calculates live fleet health: agent statuses + queue depth. + * An agent is considered ONLINE if it sent a heartbeat within the last 2 minutes. + * An agent is RUNNING if it has an ASSIGNED job with a valid lease. + */ + public Map getFleetHealth(Long orgId) { + // Get all agents for this org + List agents = orgId != null + ? agentRepository.findByOrgId(orgId) + : agentRepository.findAll(); + + LocalDateTime onlineThreshold = LocalDateTime.now().minusMinutes(2); + + // Get all currently assigned jobs (to detect which agents are running) + List assignedJobs = jobRepository.findAll().stream() + .filter(j -> "ASSIGNED".equalsIgnoreCase(j.getStatus())) + .filter(j -> j.getLeaseExpiresAt() != null && j.getLeaseExpiresAt().isAfter(LocalDateTime.now())) + .collect(Collectors.toList()); + Set busyAgentIds = assignedJobs.stream() + .map(Job::getAgentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // Count queue depth (QUEUED jobs with no agent yet) + long jobQueueDepth = jobRepository.findAll().stream() + .filter(j -> "QUEUED".equalsIgnoreCase(j.getStatus())) + .count(); + + // Also count Schedulers that are waiting to be unwrapped by an agent + long activeSchedulers = schedulerRepository.findAll().stream() + .filter(s -> "now".equals(s.getExecutionType()) && "active".equals(s.getStatus())) + .filter(s -> orgId == null || orgId.equals(s.getOrgId())) + .count(); + + long queueDepth = jobQueueDepth + activeSchedulers; + + // Build per-agent summaries + List> agentDetails = new ArrayList<>(); + int onlineCount = 0; + int runningCount = 0; + int offlineCount = 0; + + for (Agent agent : agents) { + boolean isOnline = agent.getLastSeenAt() != null && agent.getLastSeenAt().isAfter(onlineThreshold); + boolean isRunning = isOnline && busyAgentIds.contains(agent.getId()); + + String agentStatus; + if (isRunning) { agentStatus = "running"; runningCount++; } + else if (isOnline) { agentStatus = "idle"; onlineCount++; } + else { agentStatus = "offline"; offlineCount++; } + + Map detail = new LinkedHashMap<>(); + detail.put("id", agent.getId()); + detail.put("name", agent.getName()); + detail.put("os", agent.getOs()); + detail.put("agentVersion", agent.getAgentVersion()); + detail.put("status", agentStatus); + detail.put("lastSeenAt", agent.getLastSeenAt()); + agentDetails.add(detail); + } + + // Sort: running first, then idle, then offline + agentDetails.sort((a, b) -> { + String[] order = {"running", "idle", "offline"}; + int ai = Arrays.asList(order).indexOf(a.get("status").toString()); + int bi = Arrays.asList(order).indexOf(b.get("status").toString()); + return Integer.compare(ai, bi); + }); + + Map result = new LinkedHashMap<>(); + result.put("totalAgents", agents.size()); + result.put("onlineCount", onlineCount); + result.put("runningCount", runningCount); + result.put("offlineCount", offlineCount); + result.put("queueDepth", queueDepth); + result.put("agents", agentDetails); + return result; + } + + /** + * Calculates the "Time Saved" ROI metric for the org. + * + * Assumptions (industry-standard estimates): + * - Manual test execution time: 15 minutes per test run + * - QA engineer hourly rate: $50 USD + * + * Returns: + * totalRuns, totalAutomatedSecs, manualEstimateSecs, timeSavedSecs, + * hoursSaved, dollarsSaved, avgDurationSecs, + * todayRuns, weekRuns, + * manualMinsPerRun (assumption), hourlyRate (assumption) + */ + public Map getTimeSavedRoi(Long orgId) { + final int MANUAL_MINS_PER_RUN = 15; // assumed manual time per test run + final double HOURLY_RATE = 50.0; // assumed QA hourly rate in USD + + List completedExecs = executionRepository.findAllByOrderByIdDesc() + .stream() + .filter(e -> orgId == null || orgId.equals(e.getOrgId())) + .filter(e -> { + String s = e.getStatus(); + return s != null && (s.equalsIgnoreCase("success") + || s.equalsIgnoreCase("completed") + || s.equalsIgnoreCase("failed")); + }) + .collect(Collectors.toList()); + + int totalRuns = completedExecs.size(); + + // Sum actual automated execution durations + long totalAutomatedSecs = completedExecs.stream() + .filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null) + .mapToLong(e -> Math.max(0, java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds())) + .sum(); + + OptionalDouble avgDur = completedExecs.stream() + .filter(e -> e.getCreatedAt() != null && e.getFinishedAt() != null) + .mapToLong(e -> Math.max(0, java.time.Duration.between(e.getCreatedAt(), e.getFinishedAt()).getSeconds())) + .average(); + + // Manual estimate: each run would take MANUAL_MINS_PER_RUN minutes + long manualEstimateSecs = (long) totalRuns * MANUAL_MINS_PER_RUN * 60L; + + // Time saved = manual - automated (floored at 0) + long timeSavedSecs = Math.max(0, manualEstimateSecs - totalAutomatedSecs); + + double hoursSaved = timeSavedSecs / 3600.0; + double dollarsSaved = hoursSaved * HOURLY_RATE; + + // Today and this week counts + LocalDateTime startOfToday = LocalDateTime.now().toLocalDate().atStartOfDay(); + LocalDateTime startOfWeek = startOfToday.minusDays(6); + + long todayRuns = completedExecs.stream() + .filter(e -> e.getCreatedAt() != null && e.getCreatedAt().isAfter(startOfToday)) + .count(); + long weekRuns = completedExecs.stream() + .filter(e -> e.getCreatedAt() != null && e.getCreatedAt().isAfter(startOfWeek)) + .count(); + + // Daily breakdown for last 14 days (for sparkline) + List> dailyBreakdown = new ArrayList<>(); + for (int i = 13; i >= 0; i--) { + LocalDateTime dayStart = startOfToday.minusDays(i); + LocalDateTime dayEnd = dayStart.plusDays(1); + long dayRuns = completedExecs.stream() + .filter(e -> e.getCreatedAt() != null + && !e.getCreatedAt().isBefore(dayStart) + && e.getCreatedAt().isBefore(dayEnd)) + .count(); + long daySavedSecs = dayRuns * MANUAL_MINS_PER_RUN * 60L; + Map day = new LinkedHashMap<>(); + day.put("date", dayStart.toLocalDate().toString()); + day.put("runs", dayRuns); + day.put("savedMins", daySavedSecs / 60); + dailyBreakdown.add(day); + } + + Map result = new LinkedHashMap<>(); + result.put("totalRuns", totalRuns); + result.put("totalAutomatedSecs", totalAutomatedSecs); + result.put("manualEstimateSecs", manualEstimateSecs); + result.put("timeSavedSecs", timeSavedSecs); + result.put("hoursSaved", Math.round(hoursSaved * 10.0) / 10.0); + result.put("dollarsSaved", Math.round(dollarsSaved * 100.0) / 100.0); + result.put("avgDurationSecs", avgDur.isPresent() ? Math.round(avgDur.getAsDouble()) : 0); + result.put("todayRuns", todayRuns); + result.put("weekRuns", weekRuns); + result.put("manualMinsPerRun", MANUAL_MINS_PER_RUN); + result.put("hourlyRate", HOURLY_RATE); + result.put("dailyBreakdown", dailyBreakdown); + return result; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/CronExecutionEngine.java b/master/src/main/java/com/autopilot/localagent_cloud/service/CronExecutionEngine.java new file mode 100644 index 0000000..a4a55b1 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/CronExecutionEngine.java @@ -0,0 +1,205 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.repository.SchedulerRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Service +public class CronExecutionEngine { + + private static final Logger logger = LoggerFactory.getLogger(CronExecutionEngine.class); + private final SchedulerRepository schedulerRepository; + + public CronExecutionEngine(SchedulerRepository schedulerRepository) { + this.schedulerRepository = schedulerRepository; + } + + // Runs at the 0th second of every minute + @Scheduled(cron = "0 * * * * *") + public void evaluateCronSchedules() { + LocalDateTime serverNow = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); + logger.debug("Evaluating cron schedules at: {}", serverNow); + + List activeSchedules = schedulerRepository.findAll().stream() + .filter(s -> "scheduled".equals(s.getExecutionType()) && "active".equals(s.getStatus())) + .toList(); + + for (Scheduler schedule : activeSchedules) { + try { + LocalDateTime now = serverNow; + if (schedule.getTimezone() != null && !schedule.getTimezone().isBlank()) { + try { + now = LocalDateTime.now(java.time.ZoneId.of(schedule.getTimezone())).truncatedTo(ChronoUnit.MINUTES); + } catch (Exception e) { + logger.warn("Invalid timezone '{}' for schedule ID {}", schedule.getTimezone(), schedule.getId()); + } + } + + if (shouldRunOutlookSchedule(schedule, now)) { + logger.info("Outlook schedule triggered for suite: {}", schedule.getTestSuiteName()); + queueExecution(schedule); + // For 'once' recurrence, deactivate after triggering + if ("once".equals(schedule.getRecurrenceType())) { + schedule.setStatus("inactive"); + schedulerRepository.save(schedule); + } + } else if (hasLegacyCron(schedule)) { + evaluateLegacyCron(schedule, now); + } + } catch (Exception e) { + logger.error("Failed to evaluate scheduler ID {}: {}", schedule.getId(), e.getMessage(), e); + } + } + } + + /** + * Evaluates Outlook-style fields (scheduledDate, scheduledTime, recurrenceType, etc.) + * Returns true if the schedule should fire at the given time. + */ + private boolean shouldRunOutlookSchedule(Scheduler s, LocalDateTime now) { + // Only use Outlook mode if recurrenceType is set + if (s.getRecurrenceType() == null || s.getRecurrenceType().isBlank()) { + return false; + } + + LocalTime scheduledTime = s.getScheduledTime(); + if (scheduledTime == null) { + return false; + } + + // Check time match (truncated to minutes) + LocalTime nowTime = now.toLocalTime().truncatedTo(ChronoUnit.MINUTES); + LocalTime targetTime = scheduledTime.truncatedTo(ChronoUnit.MINUTES); + if (!nowTime.equals(targetTime)) { + return false; + } + + // Check recurrence end date + if (s.getRecurrenceEndDate() != null && now.toLocalDate().isAfter(s.getRecurrenceEndDate())) { + // Past end date — deactivate + s.setStatus("inactive"); + schedulerRepository.save(s); + logger.info("Deactivating scheduler ID {} — past recurrence end date", s.getId()); + return false; + } + + switch (s.getRecurrenceType()) { + case "once": { + // Must match exact date and time + LocalDate scheduledDate = s.getScheduledDate(); + if (scheduledDate == null) return false; + return now.toLocalDate().equals(scheduledDate); + } + + case "daily": { + // Check if start date has been reached + LocalDate scheduledDate = s.getScheduledDate(); + if (scheduledDate != null && now.toLocalDate().isBefore(scheduledDate)) { + return false; + } + return true; // Time already matched above + } + + case "weekly": { + // Check if start date has been reached + LocalDate scheduledDate = s.getScheduledDate(); + if (scheduledDate != null && now.toLocalDate().isBefore(scheduledDate)) { + return false; + } + // Match day of week + String recurrenceDays = s.getRecurrenceDays(); + if (recurrenceDays == null || recurrenceDays.isBlank()) return false; + String todayAbbr = now.getDayOfWeek().name().substring(0, 3); // MON, TUE, etc. + for (String day : recurrenceDays.split(",")) { + if (day.trim().equalsIgnoreCase(todayAbbr)) return true; + } + return false; + } + + case "monthly": { + // Check if start date has been reached + LocalDate scheduledDate = s.getScheduledDate(); + if (scheduledDate == null) return false; + if (now.toLocalDate().isBefore(scheduledDate)) return false; + // Match day of month + return now.getDayOfMonth() == scheduledDate.getDayOfMonth(); + } + + default: + return false; + } + } + + /** Returns true if this scheduler has a legacy cron expression and no Outlook fields */ + private boolean hasLegacyCron(Scheduler s) { + return s.getCronExpression() != null + && !s.getCronExpression().isBlank() + && (s.getRecurrenceType() == null || s.getRecurrenceType().isBlank()); + } + + /** Evaluates legacy raw cron expression (original behavior) */ + private void evaluateLegacyCron(Scheduler schedule, LocalDateTime now) { + try { + CronExpression cron = CronExpression.parse(schedule.getCronExpression()); + LocalDateTime nextExecution = cron.next(now.minusSeconds(1)); + if (nextExecution != null && nextExecution.truncatedTo(ChronoUnit.MINUTES).equals(now)) { + logger.info("Cron trigger matched for suite: {}", schedule.getTestSuiteName()); + queueExecution(schedule); + } + } catch (Exception e) { + logger.error("Failed to parse or evaluate cron expression: {} for scheduler ID: {}", + schedule.getCronExpression(), schedule.getId(), e); + } + } + + /** + * Builds a Spring cron expression string from Outlook-style fields. + * Useful for display purposes or for storing a computed cron on the entity. + */ + public static String buildCronFromOutlookFields(Scheduler s) { + if (s.getScheduledTime() == null || s.getRecurrenceType() == null) return null; + + int hour = s.getScheduledTime().getHour(); + int minute = s.getScheduledTime().getMinute(); + + return switch (s.getRecurrenceType()) { + case "once", "daily" -> String.format("0 %d %d * * *", minute, hour); + case "weekly" -> { + String days = (s.getRecurrenceDays() != null && !s.getRecurrenceDays().isBlank()) + ? s.getRecurrenceDays() + : "*"; + yield String.format("0 %d %d * * %s", minute, hour, days); + } + case "monthly" -> { + int dayOfMonth = s.getScheduledDate() != null ? s.getScheduledDate().getDayOfMonth() : 1; + yield String.format("0 %d %d %d * *", minute, hour, dayOfMonth); + } + default -> null; + }; + } + + private void queueExecution(Scheduler original) { + Scheduler job = new Scheduler(); + job.setTestSuiteId(original.getTestSuiteId()); + job.setTestSuiteName(original.getTestSuiteName()); + job.setBrowserType(original.getBrowserType()); + job.setBrowserVersion(original.getBrowserVersion()); + job.setTargetGroupId(original.getTargetGroupId()); + job.setExecutionType("now"); + job.setStatus("active"); + job.setOrgId(original.getOrgId()); + job.setEnvironmentId(original.getEnvironmentId()); + schedulerRepository.save(job); + logger.info("Queued test suite '{}' for execution via local agent polling", job.getTestSuiteName()); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/ExecutionService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/ExecutionService.java new file mode 100644 index 0000000..ddf3078 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/ExecutionService.java @@ -0,0 +1,74 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Execution; +import com.autopilot.localagent_cloud.model.Screenshot; +import com.autopilot.localagent_cloud.model.StepResult; +import com.autopilot.localagent_cloud.repository.ExecutionRepository; +import com.autopilot.localagent_cloud.repository.ScreenshotRepository; +import com.autopilot.localagent_cloud.repository.StepResultRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ExecutionService { + + private final ExecutionRepository executionRepository; + private final StepResultRepository stepResultRepository; + private final ScreenshotRepository screenshotRepository; + + public ExecutionService(ExecutionRepository executionRepository, + StepResultRepository stepResultRepository, + ScreenshotRepository screenshotRepository) { + this.executionRepository = executionRepository; + this.stepResultRepository = stepResultRepository; + this.screenshotRepository = screenshotRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? executionRepository.findAll().stream().filter(e -> orgId.equals(e.getOrgId())).toList() + : executionRepository.findAll(); + return ResponseEntity.ok(list); + } + + public ResponseEntity> getById(Long id) { + return executionRepository.findById(id).map(exec -> { + List steps = stepResultRepository.findByExecutionId(id); + List screenshots = screenshotRepository.findByExecutionId(id); + + Map detail = new HashMap<>(); + detail.put("execution", exec); + detail.put("steps", steps); + detail.put("screenshots", screenshots); + + return ResponseEntity.ok(detail); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity> getBySchedulerId(Long schedulerId) { + return executionRepository.findFirstBySchedulerIdOrderByIdDesc(schedulerId).map(exec -> { + List steps = stepResultRepository.findByExecutionId(exec.getId()); + List screenshots = screenshotRepository.findByExecutionId(exec.getId()); + + Map detail = new HashMap<>(); + detail.put("execution", exec); + detail.put("steps", steps); + detail.put("screenshots", screenshots); + + return ResponseEntity.ok(detail); + }).orElse(ResponseEntity.notFound().build()); + } + + @org.springframework.transaction.annotation.Transactional + public ResponseEntity deleteById(Long id) { + if (!executionRepository.existsById(id)) return ResponseEntity.notFound().build(); + stepResultRepository.deleteAll(stepResultRepository.findByExecutionId(id)); + screenshotRepository.deleteAll(screenshotRepository.findByExecutionId(id)); + executionRepository.deleteById(id); + return ResponseEntity.ok().build(); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/GroupService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/GroupService.java new file mode 100644 index 0000000..218e95b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/GroupService.java @@ -0,0 +1,96 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Group; +import com.autopilot.localagent_cloud.model.AgentGroupMapping; +import com.autopilot.localagent_cloud.repository.GroupRepository; +import com.autopilot.localagent_cloud.repository.AgentGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.AgentRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class GroupService { + + private final GroupRepository groupRepository; + private final AgentGroupMappingRepository agentGroupMappingRepository; + private final AgentRepository agentRepository; + + public GroupService(GroupRepository groupRepository, + AgentGroupMappingRepository agentGroupMappingRepository, + AgentRepository agentRepository) { + this.groupRepository = groupRepository; + this.agentGroupMappingRepository = agentGroupMappingRepository; + this.agentRepository = agentRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? groupRepository.findAll().stream().filter(g -> orgId.equals(g.getOrgId())).toList() + : groupRepository.findAll(); + return ResponseEntity.ok(list); + } + + public ResponseEntity create(Group group, Long orgId) { + if (group.getName() == null || group.getName().isBlank()) { + return ResponseEntity.badRequest().build(); + } + group.setOrgId(orgId); + return ResponseEntity.status(HttpStatus.CREATED).body(groupRepository.save(group)); + } + + public ResponseEntity update(Long id, Group updated) { + return groupRepository.findById(id).map(existing -> { + if (updated.getName() != null) existing.setName(updated.getName()); + if (updated.getDescription() != null) existing.setDescription(updated.getDescription()); + return ResponseEntity.ok(groupRepository.save(existing)); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity delete(Long id) { + if (!groupRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + groupRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + public ResponseEntity>> getAgentsInGroup(Long groupId) { + List mappings = agentGroupMappingRepository.findByGroupId(groupId); + List> result = new ArrayList<>(); + for (AgentGroupMapping m : mappings) { + agentRepository.findById(m.getAgentId()).ifPresent(agent -> { + Map entry = new HashMap<>(); + entry.put("mappingId", m.getId()); + entry.put("agent", agent); + result.add(entry); + }); + } + return ResponseEntity.ok(result); + } + + public ResponseEntity addAgent(Long groupId, String agentId) { + if (agentId == null || agentId.isBlank()) { + return ResponseEntity.badRequest().build(); + } + if (!groupRepository.existsById(groupId)) { + return ResponseEntity.notFound().build(); + } + AgentGroupMapping mapping = new AgentGroupMapping(); + mapping.setAgentId(agentId); + mapping.setGroupId(groupId); + return ResponseEntity.status(HttpStatus.CREATED).body(agentGroupMappingRepository.save(mapping)); + } + + @Transactional + public ResponseEntity removeAgent(Long groupId, String agentId) { + agentGroupMappingRepository.deleteByAgentIdAndGroupId(agentId, groupId); + return ResponseEntity.noContent().build(); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/OrphanedExecutionSweeper.java b/master/src/main/java/com/autopilot/localagent_cloud/service/OrphanedExecutionSweeper.java new file mode 100644 index 0000000..38ba7f0 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/OrphanedExecutionSweeper.java @@ -0,0 +1,42 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Execution; +import com.autopilot.localagent_cloud.repository.ExecutionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class OrphanedExecutionSweeper { + + private static final Logger logger = LoggerFactory.getLogger(OrphanedExecutionSweeper.class); + + private final ExecutionRepository executionRepository; + + public OrphanedExecutionSweeper(ExecutionRepository executionRepository) { + this.executionRepository = executionRepository; + } + + // Run every 5 minutes (300000 ms) + @Scheduled(fixedRate = 300000) + @Transactional + public void sweepOrphanedExecutions() { + // If an execution is stuck in "running" for more than 15 minutes, it is orphaned + LocalDateTime cutoff = LocalDateTime.now().minusMinutes(15); + List orphanedExecutions = executionRepository.findByStatusAndCreatedAtBefore("running", cutoff); + + if (!orphanedExecutions.isEmpty()) { + logger.warn("Found {} orphaned executions stuck in 'running' state. Marking them as failed.", orphanedExecutions.size()); + for (Execution execution : orphanedExecutions) { + execution.setStatus("failed"); + execution.setFinishedAt(LocalDateTime.now()); + executionRepository.save(execution); + } + } + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/S3Service.java b/master/src/main/java/com/autopilot/localagent_cloud/service/S3Service.java new file mode 100644 index 0000000..a5c8b82 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/S3Service.java @@ -0,0 +1,60 @@ +package com.autopilot.localagent_cloud.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import jakarta.annotation.PostConstruct; + +@Service +public class S3Service { + + @Value("${aws.s3.bucket:autopilot}") + private String bucketName; + + @Value("${aws.region:ap-south-1}") + private String region; + + @Value("${aws.access-key:}") + private String accessKey; + + @Value("${aws.secret-key:}") + private String secretKey; + + private S3Client s3Client; + + @PostConstruct + public void init() { + if (accessKey != null && !accessKey.isEmpty() && secretKey != null && !secretKey.isEmpty()) { + s3Client = S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } else { + // Fallback to default credentials provider (e.g. IAM role on EC2) + s3Client = S3Client.builder() + .region(Region.of(region)) + .build(); + } + } + + public String uploadImage(String fileName, byte[] imageBytes) { + String key = "screenshots/" + fileName; + + PutObjectRequest putOb = PutObjectRequest.builder() + .bucket(bucketName) + .key(key) + .contentType("image/png") + .build(); + + s3Client.putObject(putOb, RequestBody.fromBytes(imageBytes)); + + // Return public URL (assuming bucket allows public read) + return "https://" + bucketName + ".s3." + region + ".amazonaws.com/" + key; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/SchedulerService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/SchedulerService.java new file mode 100644 index 0000000..0b1ad65 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/SchedulerService.java @@ -0,0 +1,111 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.repository.SchedulerRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; + +@Service +public class SchedulerService { + + private final SchedulerRepository schedulerRepository; + + public SchedulerService(SchedulerRepository schedulerRepository) { + this.schedulerRepository = schedulerRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? schedulerRepository.findAll().stream().filter(s -> orgId.equals(s.getOrgId())).toList() + : schedulerRepository.findAll(); + return ResponseEntity.ok(list); + } + + public ResponseEntity create(Map body, Long orgId) { + Scheduler scheduler = buildSchedulerFromBody(new Scheduler(), body); + scheduler.setOrgId(orgId); + if (scheduler.getTestSuiteName() == null || scheduler.getTestSuiteName().isBlank()) { + return ResponseEntity.badRequest().build(); + } + if (scheduler.getExecutionType() == null || scheduler.getExecutionType().isBlank()) { + scheduler.setExecutionType("now"); + } + if (scheduler.getBrowserType() == null || scheduler.getBrowserType().isBlank()) { + scheduler.setBrowserType("chrome"); + } + if (scheduler.getStatus() == null || scheduler.getStatus().isBlank()) { + scheduler.setStatus("active"); + } + // Auto-compute cron expression from Outlook-style fields + if (scheduler.getRecurrenceType() != null && !scheduler.getRecurrenceType().isBlank()) { + String computed = CronExecutionEngine.buildCronFromOutlookFields(scheduler); + if (computed != null) scheduler.setCronExpression(computed); + } + return ResponseEntity.status(HttpStatus.CREATED).body(schedulerRepository.save(scheduler)); + } + + public ResponseEntity update(Long id, Map body) { + return schedulerRepository.findById(id).map(existing -> { + buildSchedulerFromBody(existing, body); + // Auto-compute cron expression from Outlook-style fields + if (existing.getRecurrenceType() != null && !existing.getRecurrenceType().isBlank()) { + String computed = CronExecutionEngine.buildCronFromOutlookFields(existing); + if (computed != null) existing.setCronExpression(computed); + } + return ResponseEntity.ok(schedulerRepository.save(existing)); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity delete(Long id) { + if (!schedulerRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + schedulerRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + /** + * Maps a JSON request body (from the Outlook-style scheduler form) onto a Scheduler entity. + * Handles both the legacy fields and the new Outlook-style fields. + */ + private Scheduler buildSchedulerFromBody(Scheduler s, Map body) { + if (body.containsKey("testSuiteName")) s.setTestSuiteName((String) body.get("testSuiteName")); + if (body.containsKey("executionType")) s.setExecutionType((String) body.get("executionType")); + if (body.containsKey("browserType")) s.setBrowserType((String) body.get("browserType")); + if (body.containsKey("status")) s.setStatus((String) body.get("status")); + if (body.containsKey("cronExpression")) s.setCronExpression((String) body.get("cronExpression")); + if (body.containsKey("testSuiteId") && body.get("testSuiteId") != null) { + s.setTestSuiteId(((Number) body.get("testSuiteId")).longValue()); + } + if (body.containsKey("environmentId") && body.get("environmentId") != null) { + s.setEnvironmentId(((Number) body.get("environmentId")).longValue()); + } + // Outlook-style fields + if (body.containsKey("recurrenceType")) s.setRecurrenceType((String) body.get("recurrenceType")); + if (body.containsKey("recurrenceDays")) s.setRecurrenceDays((String) body.get("recurrenceDays")); + if (body.containsKey("scheduledDate") && body.get("scheduledDate") != null) { + try { s.setScheduledDate(LocalDate.parse((String) body.get("scheduledDate"))); } catch (Exception ignored) {} + } + if (body.containsKey("scheduledTime") && body.get("scheduledTime") != null) { + try { s.setScheduledTime(LocalTime.parse((String) body.get("scheduledTime"))); } catch (Exception ignored) {} + } + if (body.containsKey("recurrenceEndDate") && body.get("recurrenceEndDate") != null) { + String endDateStr = (String) body.get("recurrenceEndDate"); + if (!endDateStr.isBlank()) { + try { s.setRecurrenceEndDate(LocalDate.parse(endDateStr)); } catch (Exception ignored) {} + } else { + s.setRecurrenceEndDate(null); + } + } + if (body.containsKey("timezone") && body.get("timezone") != null) { + s.setTimezone((String) body.get("timezone")); + } + return s; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseGroupService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseGroupService.java new file mode 100644 index 0000000..20c1c4c --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseGroupService.java @@ -0,0 +1,141 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.TestCaseGroup; +import com.autopilot.localagent_cloud.model.TestCaseGroupMapping; +import com.autopilot.localagent_cloud.model.TestStep; +import com.autopilot.localagent_cloud.repository.TestCaseGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.TestCaseGroupRepository; +import com.autopilot.localagent_cloud.repository.TestCaseRepository; +import com.autopilot.localagent_cloud.repository.TestStepRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +public class TestCaseGroupService { + + private final TestCaseGroupRepository testCaseGroupRepository; + private final TestCaseGroupMappingRepository testCaseGroupMappingRepository; + private final TestCaseRepository testCaseRepository; + private final TestStepRepository testStepRepository; + + public TestCaseGroupService(TestCaseGroupRepository testCaseGroupRepository, + TestCaseGroupMappingRepository testCaseGroupMappingRepository, + TestCaseRepository testCaseRepository, + TestStepRepository testStepRepository) { + this.testCaseGroupRepository = testCaseGroupRepository; + this.testCaseGroupMappingRepository = testCaseGroupMappingRepository; + this.testCaseRepository = testCaseRepository; + this.testStepRepository = testStepRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? testCaseGroupRepository.findAllByOrderByIdDesc().stream().filter(g -> orgId.equals(g.getOrgId())).toList() + : testCaseGroupRepository.findAllByOrderByIdDesc(); + return ResponseEntity.ok(list); + } + + public ResponseEntity> getById(Long id) { + return testCaseGroupRepository.findById(id).map(group -> { + List mappings = testCaseGroupMappingRepository.findByTestCaseGroupIdOrderByCaseOrder(id); + List> testCases = new ArrayList<>(); + for (TestCaseGroupMapping m : mappings) { + testCaseRepository.findById(m.getTestCaseId()).ifPresent(tc -> { + Map entry = new HashMap<>(); + entry.put("testCase", tc); + entry.put("caseOrder", m.getCaseOrder()); + entry.put("steps", testStepRepository.findByTestCaseIdOrderByStepOrder(tc.getId())); + testCases.add(entry); + }); + } + Map detail = new HashMap<>(); + detail.put("group", group); + detail.put("testCases", testCases); + return ResponseEntity.ok(detail); + }).orElse(ResponseEntity.notFound().build()); + } + + @SuppressWarnings("unchecked") + public ResponseEntity> create(Map body, Long orgId) { + String name = (String) body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + + TestCaseGroup group = new TestCaseGroup(); + group.setName(name); + group.setDescription((String) body.get("description")); + group.setStatus(body.getOrDefault("status", "active").toString()); + group.setOrgId(orgId); + group = testCaseGroupRepository.save(group); + + List testCaseIdsRaw = (List) body.get("testCaseIds"); + List testCaseIds = null; + if (testCaseIdsRaw != null) { + int order = 0; + testCaseIds = new ArrayList<>(); + java.util.Set uniqueIds = new java.util.LinkedHashSet<>(); + for (Number tcId : testCaseIdsRaw) { + if (uniqueIds.add(tcId.longValue())) { + testCaseIds.add(tcId); + TestCaseGroupMapping mapping = new TestCaseGroupMapping(); + mapping.setTestCaseGroupId(group.getId()); + mapping.setTestCaseId(tcId.longValue()); + mapping.setCaseOrder(order++); + testCaseGroupMappingRepository.save(mapping); + } + } + } + + Map result = new HashMap<>(); + result.put("group", group); + result.put("testCaseIds", testCaseIds); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + @SuppressWarnings("unchecked") + public ResponseEntity> update(Long id, Map body) { + return testCaseGroupRepository.findById(id).map(existing -> { + if (body.containsKey("name")) existing.setName((String) body.get("name")); + if (body.containsKey("description")) existing.setDescription((String) body.get("description")); + if (body.containsKey("status")) existing.setStatus((String) body.get("status")); + testCaseGroupRepository.save(existing); + + if (body.containsKey("testCaseIds")) { + testCaseGroupMappingRepository.deleteByTestCaseGroupId(id); + List testCaseIds = (List) body.get("testCaseIds"); + int order = 0; + java.util.Set uniqueIds = new java.util.LinkedHashSet<>(); + for (Number tcId : testCaseIds) { + if (uniqueIds.add(tcId.longValue())) { + TestCaseGroupMapping mapping = new TestCaseGroupMapping(); + mapping.setTestCaseGroupId(id); + mapping.setTestCaseId(tcId.longValue()); + mapping.setCaseOrder(order++); + testCaseGroupMappingRepository.save(mapping); + } + } + } + + Map result = new HashMap<>(); + result.put("group", existing); + return ResponseEntity.ok(result); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity delete(Long id) { + if (!testCaseGroupRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + testCaseGroupRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseService.java new file mode 100644 index 0000000..ea5d881 --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/TestCaseService.java @@ -0,0 +1,130 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.TestCase; +import com.autopilot.localagent_cloud.model.TestStep; +import com.autopilot.localagent_cloud.repository.TestCaseRepository; +import com.autopilot.localagent_cloud.repository.TestStepRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +public class TestCaseService { + + private final TestCaseRepository testCaseRepository; + private final TestStepRepository testStepRepository; + + public TestCaseService(TestCaseRepository testCaseRepository, + TestStepRepository testStepRepository) { + this.testCaseRepository = testCaseRepository; + this.testStepRepository = testStepRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? testCaseRepository.findAllByOrderByIdDesc().stream().filter(t -> orgId.equals(t.getOrgId())).toList() + : testCaseRepository.findAllByOrderByIdDesc(); + return ResponseEntity.ok(list); + } + + public ResponseEntity> getById(Long id) { + return testCaseRepository.findById(id).map(tc -> { + List steps = testStepRepository.findByTestCaseIdOrderByStepOrder(id); + Map detail = new HashMap<>(); + detail.put("testCase", tc); + detail.put("steps", steps); + return ResponseEntity.ok(detail); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity> create(Map body, Long orgId) { + String name = (String) body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + + TestCase tc = new TestCase(); + tc.setName(name); + tc.setDescription((String) body.get("description")); + tc.setStatus(body.getOrDefault("status", "active").toString()); + if (body.containsKey("isComponent")) { + Object ic = body.get("isComponent"); + tc.setIsComponent(ic instanceof Boolean ? (Boolean) ic : Boolean.parseBoolean(ic.toString())); + } + tc.setOrgId(orgId); + tc = testCaseRepository.save(tc); + + List savedSteps = saveStepsFromBody(body, tc.getId()); + + Map result = new HashMap<>(); + result.put("testCase", tc); + result.put("steps", savedSteps); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + public ResponseEntity> update(Long id, Map body) { + return testCaseRepository.findById(id).map(existing -> { + if (body.containsKey("name")) existing.setName((String) body.get("name")); + if (body.containsKey("description")) existing.setDescription((String) body.get("description")); + if (body.containsKey("status")) existing.setStatus((String) body.get("status")); + if (body.containsKey("isComponent")) { + Object ic = body.get("isComponent"); + existing.setIsComponent(ic instanceof Boolean ? (Boolean) ic : Boolean.parseBoolean(ic.toString())); + } + testCaseRepository.save(existing); + + List savedSteps; + if (body.containsKey("steps")) { + testStepRepository.deleteByTestCaseId(id); + savedSteps = saveStepsFromBody(body, id); + } else { + savedSteps = testStepRepository.findByTestCaseIdOrderByStepOrder(id); + } + + Map result = new HashMap<>(); + result.put("testCase", existing); + result.put("steps", savedSteps); + return ResponseEntity.ok(result); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity delete(Long id) { + if (!testCaseRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + testCaseRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } + + @SuppressWarnings("unchecked") + private List saveStepsFromBody(Map body, Long testCaseId) { + List saved = new ArrayList<>(); + Object stepsObj = body.get("steps"); + if (stepsObj instanceof List) { + List> stepsList = (List>) stepsObj; + int order = 1; + for (Map s : stepsList) { + TestStep step = new TestStep(); + step.setTestCaseId(testCaseId); + step.setStepOrder(s.containsKey("stepOrder") ? ((Number) s.get("stepOrder")).intValue() : order); + step.setActionName((String) s.get("actionName")); + step.setLocatorType((String) s.get("locatorType")); + step.setLocatorValue((String) s.get("locatorValue")); + step.setTestData((String) s.get("testData")); + step.setStepType((String) s.getOrDefault("stepType", "ACTION")); + step.setExpectedValue((String) s.get("expectedValue")); + step.setDescription((String) s.get("description")); + saved.add(testStepRepository.save(step)); + order++; + } + } + return saved; + } +} diff --git a/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java b/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java new file mode 100644 index 0000000..ecc2d0b --- /dev/null +++ b/master/src/main/java/com/autopilot/localagent_cloud/service/TestSuiteService.java @@ -0,0 +1,202 @@ +package com.autopilot.localagent_cloud.service; + +import com.autopilot.localagent_cloud.model.Scheduler; +import com.autopilot.localagent_cloud.model.TestCaseGroupMapping; +import com.autopilot.localagent_cloud.model.TestStep; +import com.autopilot.localagent_cloud.model.TestSuite; +import com.autopilot.localagent_cloud.model.TestSuiteGroupMapping; +import com.autopilot.localagent_cloud.repository.SchedulerRepository; +import com.autopilot.localagent_cloud.repository.TestCaseGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.TestCaseGroupRepository; +import com.autopilot.localagent_cloud.repository.TestCaseRepository; +import com.autopilot.localagent_cloud.repository.TestStepRepository; +import com.autopilot.localagent_cloud.repository.TestSuiteGroupMappingRepository; +import com.autopilot.localagent_cloud.repository.TestSuiteRepository; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +public class TestSuiteService { + + private final TestSuiteRepository testSuiteRepository; + private final TestSuiteGroupMappingRepository testSuiteGroupMappingRepository; + private final TestCaseGroupRepository testCaseGroupRepository; + private final TestCaseGroupMappingRepository testCaseGroupMappingRepository; + private final TestCaseRepository testCaseRepository; + private final TestStepRepository testStepRepository; + private final SchedulerRepository schedulerRepository; + + public TestSuiteService(TestSuiteRepository testSuiteRepository, + TestSuiteGroupMappingRepository testSuiteGroupMappingRepository, + TestCaseGroupRepository testCaseGroupRepository, + TestCaseGroupMappingRepository testCaseGroupMappingRepository, + TestCaseRepository testCaseRepository, + TestStepRepository testStepRepository, + SchedulerRepository schedulerRepository) { + this.testSuiteRepository = testSuiteRepository; + this.testSuiteGroupMappingRepository = testSuiteGroupMappingRepository; + this.testCaseGroupRepository = testCaseGroupRepository; + this.testCaseGroupMappingRepository = testCaseGroupMappingRepository; + this.testCaseRepository = testCaseRepository; + this.testStepRepository = testStepRepository; + this.schedulerRepository = schedulerRepository; + } + + public ResponseEntity> getAll(Long orgId) { + List list = orgId != null + ? testSuiteRepository.findAllByOrderByIdDesc().stream().filter(s -> orgId.equals(s.getOrgId())).toList() + : testSuiteRepository.findAllByOrderByIdDesc(); + return ResponseEntity.ok(list); + } + + public ResponseEntity> getById(Long id) { + return testSuiteRepository.findById(id).map(suite -> { + List mappings = testSuiteGroupMappingRepository.findByTestSuiteIdOrderByGroupOrder(id); + List> groups = new ArrayList<>(); + for (TestSuiteGroupMapping m : mappings) { + testCaseGroupRepository.findById(m.getTestCaseGroupId()).ifPresent(grp -> { + List caseMappings = testCaseGroupMappingRepository + .findByTestCaseGroupIdOrderByCaseOrder(grp.getId()); + List> testCases = new ArrayList<>(); + for (TestCaseGroupMapping cm : caseMappings) { + testCaseRepository.findById(cm.getTestCaseId()).ifPresent(tc -> { + Map tcEntry = new HashMap<>(); + tcEntry.put("testCase", tc); + tcEntry.put("caseOrder", cm.getCaseOrder()); + tcEntry.put("steps", testStepRepository.findByTestCaseIdOrderByStepOrder(tc.getId())); + testCases.add(tcEntry); + }); + } + + Map entry = new HashMap<>(); + entry.put("group", grp); + entry.put("groupOrder", m.getGroupOrder()); + entry.put("testCases", testCases); + groups.add(entry); + }); + } + + Map detail = new HashMap<>(); + detail.put("suite", suite); + detail.put("groups", groups); + return ResponseEntity.ok(detail); + }).orElse(ResponseEntity.notFound().build()); + } + + @Transactional + @SuppressWarnings("unchecked") + public ResponseEntity> create(Map body, Long orgId) { + String name = (String) body.get("name"); + if (name == null || name.isBlank()) { + return ResponseEntity.badRequest().build(); + } + + TestSuite suite = new TestSuite(); + suite.setName(name); + suite.setDescription((String) body.get("description")); + suite.setBrowserType(body.getOrDefault("browserType", "chrome").toString()); + suite.setStatus(body.getOrDefault("status", "active").toString()); + suite.setOrgId(orgId); + suite = testSuiteRepository.save(suite); + + List groupIds = (List) body.get("testCaseGroupIds"); + if (groupIds != null) { + int order = 0; + for (Number gId : groupIds) { + TestSuiteGroupMapping mapping = new TestSuiteGroupMapping(); + mapping.setTestSuiteId(suite.getId()); + mapping.setTestCaseGroupId(gId.longValue()); + mapping.setGroupOrder(order++); + testSuiteGroupMappingRepository.save(mapping); + } + } + + Map result = new HashMap<>(); + result.put("suite", suite); + result.put("testCaseGroupIds", groupIds); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + } + + @Transactional + @SuppressWarnings("unchecked") + public ResponseEntity> update(Long id, Map body) { + return testSuiteRepository.findById(id).map(existing -> { + if (body.containsKey("name")) existing.setName((String) body.get("name")); + if (body.containsKey("description")) existing.setDescription((String) body.get("description")); + if (body.containsKey("browserType")) existing.setBrowserType((String) body.get("browserType")); + if (body.containsKey("status")) existing.setStatus((String) body.get("status")); + testSuiteRepository.save(existing); + + if (body.containsKey("testCaseGroupIds")) { + testSuiteGroupMappingRepository.deleteByTestSuiteId(id); + testSuiteGroupMappingRepository.flush(); + List groupIds = (List) body.get("testCaseGroupIds"); + int order = 0; + for (Number gId : groupIds) { + TestSuiteGroupMapping mapping = new TestSuiteGroupMapping(); + mapping.setTestSuiteId(id); + mapping.setTestCaseGroupId(gId.longValue()); + mapping.setGroupOrder(order++); + testSuiteGroupMappingRepository.save(mapping); + } + } + + Map result = new HashMap<>(); + result.put("suite", existing); + return ResponseEntity.ok(result); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity> run(Long id, Map body, Long orgId) { + return testSuiteRepository.findById(id).map(suite -> { + String browser = "chrome"; + if (body != null && body.containsKey("browserType")) { + browser = (String) body.get("browserType"); + } else if (suite.getBrowserType() != null) { + browser = suite.getBrowserType(); + } + + Scheduler scheduler = new Scheduler(); + scheduler.setTestSuiteName(suite.getName()); + scheduler.setTestSuiteId(suite.getId()); + scheduler.setExecutionType("now"); + scheduler.setBrowserType(browser); + scheduler.setStatus("active"); + scheduler.setOrgId(orgId); + + if (body != null && body.containsKey("environmentId")) { + scheduler.setEnvironmentId(((Number) body.get("environmentId")).longValue()); + } + if (body != null && body.containsKey("targetGroupId")) { + scheduler.setTargetGroupId(((Number) body.get("targetGroupId")).longValue()); + } + if (body != null && body.containsKey("browserVersion")) { + scheduler.setBrowserVersion((String) body.get("browserVersion")); + } + + scheduler = schedulerRepository.save(scheduler); + + Map result = new HashMap<>(); + result.put("scheduler", scheduler); + result.put("suite", suite); + result.put("message", "Test suite queued for immediate execution"); + return ResponseEntity.status(HttpStatus.CREATED).body(result); + }).orElse(ResponseEntity.notFound().build()); + } + + public ResponseEntity delete(Long id) { + if (!testSuiteRepository.existsById(id)) { + return ResponseEntity.notFound().build(); + } + testSuiteRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/master/src/main/resources/application-local.yml b/master/src/main/resources/application-local.yml new file mode 100644 index 0000000..d538f53 --- /dev/null +++ b/master/src/main/resources/application-local.yml @@ -0,0 +1,14 @@ +# This file is active when running on the 'dev' branch with the 'local' profile. +spring: + datasource: + url: jdbc:postgresql://localhost:5432/localagent_cloud + username: localagent + password: localagent + jpa: + hibernate: + ddl-auto: update # Useful for local dev to auto-update schema + +# Automatically inject a mock JWT secret for local dev so you don't have to set it in env vars +autopilot: + jwt: + secret: "local_dev_super_secret_key_which_must_be_at_least_256_bits_long_for_hs256_algorithm" diff --git a/master/src/main/resources/application-prod.yml b/master/src/main/resources/application-prod.yml new file mode 100644 index 0000000..643721d --- /dev/null +++ b/master/src/main/resources/application-prod.yml @@ -0,0 +1,21 @@ +# Production profile — all values come from environment variables +spring: + datasource: + url: ${DB_URL} + username: ${DB_USER} + password: ${DB_PASS} + jpa: + hibernate: + ddl-auto: validate + flyway: + enabled: true + +autopilot: + jwt: + secret: ${JWT_SECRET} + expiration-ms: 1200000 + +logging: + level: + root: WARN + com.autopilot: INFO diff --git a/master/src/main/resources/application.yml b/master/src/main/resources/application.yml new file mode 100644 index 0000000..6e3c937 --- /dev/null +++ b/master/src/main/resources/application.yml @@ -0,0 +1,59 @@ +server: + port: 9090 + tomcat: + max-http-form-post-size: 52428800 # 50MB + max-swallow-size: 52428800 # 50MB + +spring: + profiles: + active: local + application: + name: autopilot-master + datasource: + # Production default: PostgreSQL (set DB_URL/DB_USER/DB_PASS on AWS for RDS) + url: ${DB_URL:jdbc:postgresql://localhost:5432/localagent_cloud} + username: ${DB_USER:localagent} + password: ${DB_PASS:localagent} + driver-class-name: org.postgresql.Driver + hikari: + minimum-idle: 1 + maximum-pool-size: 10 + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + show-sql: false + flyway: + enabled: true + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + +# JWT Configuration +autopilot: + jwt: + # SECURITY: No fallback default — app will fail at startup if JWT_SECRET env var is not set. + secret: ${JWT_SECRET} + expiration-ms: 1200000 # 20 minutes + +--- +spring: + config: + activate: + on-profile: h2 + datasource: + url: jdbc:h2:file:./data/localagent-cloud;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + flyway: + enabled: false diff --git a/master/src/main/resources/db/migration/V10__device_pairing.sql b/master/src/main/resources/db/migration/V10__device_pairing.sql new file mode 100644 index 0000000..3c4b4a7 --- /dev/null +++ b/master/src/main/resources/db/migration/V10__device_pairing.sql @@ -0,0 +1,12 @@ +CREATE TABLE device_pairings ( + id BIGSERIAL PRIMARY KEY, + pairing_code VARCHAR(10) NOT NULL UNIQUE, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + agent_token VARCHAR(255), + org_id BIGINT, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + expires_at TIMESTAMP NOT NULL +); + +-- Index for quick lookup by pairing code +CREATE INDEX idx_device_pairings_code ON device_pairings(pairing_code); diff --git a/master/src/main/resources/db/migration/V11__add_org_execution_id.sql b/master/src/main/resources/db/migration/V11__add_org_execution_id.sql new file mode 100644 index 0000000..fce6c7c --- /dev/null +++ b/master/src/main/resources/db/migration/V11__add_org_execution_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE executions ADD COLUMN org_execution_id BIGINT; +UPDATE executions SET org_execution_id = id WHERE org_execution_id IS NULL; diff --git a/master/src/main/resources/db/migration/V12__enterprise_foundations.sql b/master/src/main/resources/db/migration/V12__enterprise_foundations.sql new file mode 100644 index 0000000..610d3c2 --- /dev/null +++ b/master/src/main/resources/db/migration/V12__enterprise_foundations.sql @@ -0,0 +1,30 @@ +-- Enterprise Sprint 1: Assertions Engine, Variables System, Environment Management + +-- 1. Assertions Engine: extend test_steps +ALTER TABLE test_steps ADD COLUMN IF NOT EXISTS step_type VARCHAR(20) DEFAULT 'ACTION'; +ALTER TABLE test_steps ADD COLUMN IF NOT EXISTS expected_value TEXT; + +-- 2. Assertions Engine: extend step_results with actual value captured at runtime +ALTER TABLE step_results ADD COLUMN IF NOT EXISTS actual_value TEXT; +ALTER TABLE step_results ADD COLUMN IF NOT EXISTS step_type VARCHAR(20) DEFAULT 'ACTION'; + +-- 3. Environments +CREATE TABLE IF NOT EXISTS environments ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT, + name VARCHAR(100) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 4. Variables (Global + Suite + Environment scoped) +CREATE TABLE IF NOT EXISTS variables ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT, + scope VARCHAR(20) NOT NULL DEFAULT 'GLOBAL', -- GLOBAL, SUITE, ENVIRONMENT + scope_id BIGINT, -- suite_id or environment_id if scoped + key_name VARCHAR(100) NOT NULL, + value TEXT, + is_secret BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); diff --git a/master/src/main/resources/db/migration/V13__add_environment_id.sql b/master/src/main/resources/db/migration/V13__add_environment_id.sql new file mode 100644 index 0000000..513e7b0 --- /dev/null +++ b/master/src/main/resources/db/migration/V13__add_environment_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE executions ADD COLUMN IF NOT EXISTS environment_id BIGINT; +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS environment_id BIGINT; diff --git a/master/src/main/resources/db/migration/V14__add_pools_and_browsers.sql b/master/src/main/resources/db/migration/V14__add_pools_and_browsers.sql new file mode 100644 index 0000000..e79096e --- /dev/null +++ b/master/src/main/resources/db/migration/V14__add_pools_and_browsers.sql @@ -0,0 +1,10 @@ +ALTER TABLE executions ADD COLUMN IF NOT EXISTS target_group_id BIGINT; +ALTER TABLE executions ADD COLUMN IF NOT EXISTS browser_type VARCHAR(50); +ALTER TABLE executions ADD COLUMN IF NOT EXISTS browser_version VARCHAR(50); + +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS target_group_id BIGINT; +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS browser_type VARCHAR(50); +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS browser_version VARCHAR(50); + +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS browser_type VARCHAR(50); +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS browser_version VARCHAR(50); diff --git a/master/src/main/resources/db/migration/V15__add_test_case_to_jobs.sql b/master/src/main/resources/db/migration/V15__add_test_case_to_jobs.sql new file mode 100644 index 0000000..26ebbd3 --- /dev/null +++ b/master/src/main/resources/db/migration/V15__add_test_case_to_jobs.sql @@ -0,0 +1,2 @@ +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS test_case_id BIGINT; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS target_group_id BIGINT; diff --git a/master/src/main/resources/db/migration/V16__create_api_keys_table.sql b/master/src/main/resources/db/migration/V16__create_api_keys_table.sql new file mode 100644 index 0000000..48187fd --- /dev/null +++ b/master/src/main/resources/db/migration/V16__create_api_keys_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE api_keys ( + id SERIAL PRIMARY KEY, + org_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP, + last_used_at TIMESTAMP WITHOUT TIME ZONE +); diff --git a/master/src/main/resources/db/migration/V17__fix_api_keys_id_type.sql b/master/src/main/resources/db/migration/V17__fix_api_keys_id_type.sql new file mode 100644 index 0000000..d59954a --- /dev/null +++ b/master/src/main/resources/db/migration/V17__fix_api_keys_id_type.sql @@ -0,0 +1 @@ +ALTER TABLE api_keys ALTER COLUMN id TYPE BIGINT; diff --git a/master/src/main/resources/db/migration/V18__add_subdomain_to_orgs.sql b/master/src/main/resources/db/migration/V18__add_subdomain_to_orgs.sql new file mode 100644 index 0000000..52d5271 --- /dev/null +++ b/master/src/main/resources/db/migration/V18__add_subdomain_to_orgs.sql @@ -0,0 +1,4 @@ +ALTER TABLE organisations ADD COLUMN subdomain VARCHAR(255); +-- For existing data, set subdomain to a sanitized version of the name or a random UUID to avoid null constraints before making it unique +UPDATE organisations SET subdomain = LOWER(REGEXP_REPLACE(name, '[^a-zA-Z0-9]', '-', 'g')) || '-' || id WHERE subdomain IS NULL; +ALTER TABLE organisations ADD CONSTRAINT uq_org_subdomain UNIQUE (subdomain); diff --git a/master/src/main/resources/db/migration/V19__add_requires_password_change.sql b/master/src/main/resources/db/migration/V19__add_requires_password_change.sql new file mode 100644 index 0000000..ff2d8c3 --- /dev/null +++ b/master/src/main/resources/db/migration/V19__add_requires_password_change.sql @@ -0,0 +1 @@ +ALTER TABLE app_users ADD COLUMN requires_password_change BOOLEAN DEFAULT false; diff --git a/master/src/main/resources/db/migration/V1__init.sql b/master/src/main/resources/db/migration/V1__init.sql new file mode 100644 index 0000000..c600159 --- /dev/null +++ b/master/src/main/resources/db/migration/V1__init.sql @@ -0,0 +1,16 @@ +-- Initial schema for localagent-cloud on PostgreSQL. +-- This mirrors the JPA entity com.autopropel.localagent_cloud.persistence.JobRecord. + +CREATE TABLE IF NOT EXISTS cloud_jobs ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT, + reference_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_cloud_jobs_agent_status_created + ON cloud_jobs (agent_id, status, created_at); + diff --git a/master/src/main/resources/db/migration/V20__add_org_public_id.sql b/master/src/main/resources/db/migration/V20__add_org_public_id.sql new file mode 100644 index 0000000..3242369 --- /dev/null +++ b/master/src/main/resources/db/migration/V20__add_org_public_id.sql @@ -0,0 +1,4 @@ +ALTER TABLE organisations ADD COLUMN public_id VARCHAR(20); +UPDATE organisations SET public_id = UPPER(SUBSTRING(name, 1, 3)) || '-' || LPAD(FLOOR(RANDOM() * 99999)::TEXT, 5, '0') WHERE public_id IS NULL; +ALTER TABLE organisations ALTER COLUMN public_id SET NOT NULL; +CREATE UNIQUE INDEX idx_organisations_public_id ON organisations(public_id); diff --git a/master/src/main/resources/db/migration/V21__add_timezone.sql b/master/src/main/resources/db/migration/V21__add_timezone.sql new file mode 100644 index 0000000..e645d1d --- /dev/null +++ b/master/src/main/resources/db/migration/V21__add_timezone.sql @@ -0,0 +1 @@ +ALTER TABLE schedulers ADD COLUMN timezone VARCHAR(100); diff --git a/master/src/main/resources/db/migration/V22__create_audit_logs_table.sql b/master/src/main/resources/db/migration/V22__create_audit_logs_table.sql new file mode 100644 index 0000000..e915ed4 --- /dev/null +++ b/master/src/main/resources/db/migration/V22__create_audit_logs_table.sql @@ -0,0 +1,10 @@ +CREATE TABLE audit_logs ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + org_id BIGINT NOT NULL, + user_email VARCHAR(255), + action VARCHAR(255) NOT NULL, + entity_type VARCHAR(255) NOT NULL, + entity_id VARCHAR(255), + details VARCHAR(1000), + created_at TIMESTAMP WITHOUT TIME ZONE +); diff --git a/master/src/main/resources/db/migration/V23__add_execution_scheduler_and_ai.sql b/master/src/main/resources/db/migration/V23__add_execution_scheduler_and_ai.sql new file mode 100644 index 0000000..432151d --- /dev/null +++ b/master/src/main/resources/db/migration/V23__add_execution_scheduler_and_ai.sql @@ -0,0 +1,2 @@ +ALTER TABLE executions ADD COLUMN scheduler_id BIGINT; +ALTER TABLE executions ADD COLUMN ai_analysis TEXT; diff --git a/master/src/main/resources/db/migration/V24__create_datasets_table.sql b/master/src/main/resources/db/migration/V24__create_datasets_table.sql new file mode 100644 index 0000000..d584a4c --- /dev/null +++ b/master/src/main/resources/db/migration/V24__create_datasets_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE datasets ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT, + name VARCHAR(255) NOT NULL, + description TEXT, + headers TEXT, + rows TEXT, + row_count INTEGER, + created_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP WITHOUT TIME ZONE +); diff --git a/master/src/main/resources/db/migration/V25__add_is_component_to_test_cases.sql b/master/src/main/resources/db/migration/V25__add_is_component_to_test_cases.sql new file mode 100644 index 0000000..e43ab71 --- /dev/null +++ b/master/src/main/resources/db/migration/V25__add_is_component_to_test_cases.sql @@ -0,0 +1 @@ +ALTER TABLE test_cases ADD COLUMN is_component BOOLEAN DEFAULT false; diff --git a/master/src/main/resources/db/migration/V2__mvp_schema.sql b/master/src/main/resources/db/migration/V2__mvp_schema.sql new file mode 100644 index 0000000..018e669 --- /dev/null +++ b/master/src/main/resources/db/migration/V2__mvp_schema.sql @@ -0,0 +1,51 @@ +-- Registered local machines +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + os TEXT, + agent_version TEXT, + last_seen_at TIMESTAMP NOT NULL, + capabilities_json TEXT +); + +-- Run requests +CREATE TABLE IF NOT EXISTS executions ( + id BIGSERIAL PRIMARY KEY, + status TEXT NOT NULL, -- QUEUED, RUNNING, SUCCESS, FAILED + created_at TIMESTAMP NOT NULL, + finished_at TIMESTAMP, + environment_json TEXT +); + +-- DB Queue Work Unit +CREATE TABLE IF NOT EXISTS jobs ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT REFERENCES executions(id) ON DELETE CASCADE, + agent_id TEXT REFERENCES agents(id) ON DELETE SET NULL, + status TEXT NOT NULL, -- QUEUED, ASSIGNED, COMPLETED, TIMEOUT + lease_expires_at TIMESTAMP, + payload_json TEXT NOT NULL +); + +-- Per-step execution outcome +CREATE TABLE IF NOT EXISTS step_results ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT REFERENCES executions(id) ON DELETE CASCADE, + step_index INT NOT NULL, + action_name TEXT NOT NULL, + executed_status INT NOT NULL, + result_status INT NOT NULL, + error_json TEXT +); + +-- Screenshot artifact metadata +CREATE TABLE IF NOT EXISTS screenshots ( + id BIGSERIAL PRIMARY KEY, + execution_id BIGINT REFERENCES executions(id) ON DELETE CASCADE, + step_result_id BIGINT REFERENCES step_results(id) ON DELETE SET NULL, + file_name TEXT NOT NULL, + content_type TEXT NOT NULL, + storage_path TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_jobs_agent_status ON jobs (agent_id, status); diff --git a/master/src/main/resources/db/migration/V3__dashboard_tables.sql b/master/src/main/resources/db/migration/V3__dashboard_tables.sql new file mode 100644 index 0000000..7df31ea --- /dev/null +++ b/master/src/main/resources/db/migration/V3__dashboard_tables.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS schedulers ( + id BIGSERIAL PRIMARY KEY, + test_suite_name TEXT NOT NULL, + execution_type TEXT NOT NULL, + browser_type TEXT NOT NULL, + status TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/master/src/main/resources/db/migration/V4__test_management_tables.sql b/master/src/main/resources/db/migration/V4__test_management_tables.sql new file mode 100644 index 0000000..de74272 --- /dev/null +++ b/master/src/main/resources/db/migration/V4__test_management_tables.sql @@ -0,0 +1,62 @@ +-- Level 1: Test Cases (a named test scenario containing ordered steps) +CREATE TABLE IF NOT EXISTS test_cases ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Level 1a: Test Steps (individual actions within a test case) +CREATE TABLE IF NOT EXISTS test_steps ( + id BIGSERIAL PRIMARY KEY, + test_case_id BIGINT NOT NULL REFERENCES test_cases(id) ON DELETE CASCADE, + step_order INT NOT NULL, + action_name TEXT NOT NULL, + locator_type TEXT, + locator_value TEXT, + test_data TEXT, + description TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_test_steps_case ON test_steps (test_case_id, step_order); + +-- Level 2: Test Case Groups (logical grouping of test cases) +CREATE TABLE IF NOT EXISTS test_case_groups ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Level 2a: Mapping test cases to groups (many-to-many with ordering) +CREATE TABLE IF NOT EXISTS test_case_group_mappings ( + id BIGSERIAL PRIMARY KEY, + test_case_group_id BIGINT NOT NULL REFERENCES test_case_groups(id) ON DELETE CASCADE, + test_case_id BIGINT NOT NULL REFERENCES test_cases(id) ON DELETE CASCADE, + case_order INT NOT NULL DEFAULT 0, + UNIQUE (test_case_group_id, test_case_id) +); + +-- Level 3: Test Suites (top-level collection of groups) +CREATE TABLE IF NOT EXISTS test_suites ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + browser_type TEXT NOT NULL DEFAULT 'chrome', + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- Level 3a: Mapping groups to suites (many-to-many with ordering) +CREATE TABLE IF NOT EXISTS test_suite_group_mappings ( + id BIGSERIAL PRIMARY KEY, + test_suite_id BIGINT NOT NULL REFERENCES test_suites(id) ON DELETE CASCADE, + test_case_group_id BIGINT NOT NULL REFERENCES test_case_groups(id) ON DELETE CASCADE, + group_order INT NOT NULL DEFAULT 0, + UNIQUE (test_suite_id, test_case_group_id) +); diff --git a/master/src/main/resources/db/migration/V5__agent_group_and_scheduler_links.sql b/master/src/main/resources/db/migration/V5__agent_group_and_scheduler_links.sql new file mode 100644 index 0000000..3a5499b --- /dev/null +++ b/master/src/main/resources/db/migration/V5__agent_group_and_scheduler_links.sql @@ -0,0 +1,10 @@ +-- Agent-Group mapping +CREATE TABLE IF NOT EXISTS agent_group_mappings ( + id BIGSERIAL PRIMARY KEY, + agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + UNIQUE (agent_id, group_id) +); + +-- Link schedulers to test suites +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS test_suite_id BIGINT REFERENCES test_suites(id) ON DELETE SET NULL; diff --git a/master/src/main/resources/db/migration/V6__add_cron_expression_to_schedulers.sql b/master/src/main/resources/db/migration/V6__add_cron_expression_to_schedulers.sql new file mode 100644 index 0000000..09e1a96 --- /dev/null +++ b/master/src/main/resources/db/migration/V6__add_cron_expression_to_schedulers.sql @@ -0,0 +1 @@ +ALTER TABLE schedulers ADD COLUMN cron_expression VARCHAR(255); diff --git a/master/src/main/resources/db/migration/V7__scheduler_outlook_fields.sql b/master/src/main/resources/db/migration/V7__scheduler_outlook_fields.sql new file mode 100644 index 0000000..98d2530 --- /dev/null +++ b/master/src/main/resources/db/migration/V7__scheduler_outlook_fields.sql @@ -0,0 +1,9 @@ +-- Outlook-style scheduler fields +-- Allows users to set date/time/recurrence without writing raw cron expressions +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS scheduled_date DATE; +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS scheduled_time TIME; +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS recurrence_type VARCHAR(20); +-- 'once', 'daily', 'weekly', 'monthly' +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS recurrence_days VARCHAR(50); +-- comma-separated day abbreviations for weekly: e.g. 'MON,WED,FRI' +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS recurrence_end_date DATE; diff --git a/master/src/main/resources/db/migration/V8__auth_and_multitenancy.sql b/master/src/main/resources/db/migration/V8__auth_and_multitenancy.sql new file mode 100644 index 0000000..8679619 --- /dev/null +++ b/master/src/main/resources/db/migration/V8__auth_and_multitenancy.sql @@ -0,0 +1,57 @@ +-- V8: Authentication & Multi-tenancy +-- Adds organisations, users, agent_tokens tables +-- Adds org_id column to all existing data tables + +-- ─── Core auth tables ──────────────────────────────────────────────────────── + +CREATE TABLE IF NOT EXISTS organisations ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + plan TEXT NOT NULL DEFAULT 'trial', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS app_users ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + full_name TEXT, + role TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS agent_tokens ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + token TEXT UNIQUE NOT NULL, + label TEXT, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +-- ─── Add org_id to all existing tables ─────────────────────────────────────── + +ALTER TABLE schedulers ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE executions ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE agents ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE groups ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE test_cases ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE test_suites ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE test_case_groups ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE step_results ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; +ALTER TABLE screenshots ADD COLUMN IF NOT EXISTS org_id BIGINT REFERENCES organisations(id) ON DELETE CASCADE; + +-- ─── Seed a default "dev" organisation so existing data still works ────────── +INSERT INTO organisations (id, name, plan) VALUES (1, 'Default Org', 'enterprise') +ON CONFLICT (id) DO NOTHING; + +-- Assign all existing rows to org 1 (backward-compat for existing dev data) +UPDATE schedulers SET org_id = 1 WHERE org_id IS NULL; +UPDATE executions SET org_id = 1 WHERE org_id IS NULL; +UPDATE agents SET org_id = 1 WHERE org_id IS NULL; +UPDATE groups SET org_id = 1 WHERE org_id IS NULL; +UPDATE test_cases SET org_id = 1 WHERE org_id IS NULL; +UPDATE test_suites SET org_id = 1 WHERE org_id IS NULL; +UPDATE test_case_groups SET org_id = 1 WHERE org_id IS NULL; +UPDATE step_results SET org_id = 1 WHERE org_id IS NULL; +UPDATE screenshots SET org_id = 1 WHERE org_id IS NULL; diff --git a/master/src/main/resources/db/migration/V9__fix_organisation_sequence.sql b/master/src/main/resources/db/migration/V9__fix_organisation_sequence.sql new file mode 100644 index 0000000..59c746b --- /dev/null +++ b/master/src/main/resources/db/migration/V9__fix_organisation_sequence.sql @@ -0,0 +1,6 @@ +-- V9: Fix organisation sequence +-- Because V8 manually inserted id=1, the sequence remained at 1. +-- When Hibernate tries to insert a new row, it uses nextval which returns 1, causing a PK violation. +-- This advances the sequence to the maximum id. + +SELECT setval('organisations_id_seq', COALESCE((SELECT MAX(id)+1 FROM organisations), 1), false); diff --git a/master/src/main/resources/static/favicon.svg b/master/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/master/src/main/resources/static/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/master/src/main/resources/static/icons.svg b/master/src/main/resources/static/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/master/src/main/resources/static/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/master/src/main/resources/static/install.ps1 b/master/src/main/resources/static/install.ps1 new file mode 100644 index 0000000..8bcd79e --- /dev/null +++ b/master/src/main/resources/static/install.ps1 @@ -0,0 +1,31 @@ +param ( + [Parameter(Mandatory=$true)] + [string]$Token, + + [string]$InstallDir = "C:\AutoPilot\Agent", + [string]$CloudUrl = "http://13.232.42.59" +) + +Write-Host "[*] Installing AutoPilot Agent..." -ForegroundColor Cyan +New-Item -Path $InstallDir -ItemType Directory -Force | Out-Null +Set-Location -Path $InstallDir + +Write-Host "[*] Downloading Agent JAR..." -ForegroundColor Cyan +# In production, this points to the real hosted JAR +Invoke-WebRequest -Uri "$CloudUrl/agent/localagent-java.jar" -OutFile "localagent-java.jar" + +Write-Host "[*] Configuring Agent..." -ForegroundColor Cyan +$ConfigLines = @( + "localagent:", + " cloud-url: $CloudUrl", + " token: $Token", + " polling-enabled: true" +) +Set-Content -Path "application.yml" -Value $ConfigLines + +Write-Host "" +Write-Host "[+] Setup Complete!" -ForegroundColor Green +Write-Host "To start the agent in the background (as a service), we would register WinSW here." +Write-Host "For now, you can start it manually with:" -ForegroundColor Yellow +Write-Host " cd $InstallDir" +Write-Host " java -jar localagent-java.jar" diff --git a/master/src/main/resources/static/install.sh b/master/src/main/resources/static/install.sh new file mode 100644 index 0000000..c27e1db --- /dev/null +++ b/master/src/main/resources/static/install.sh @@ -0,0 +1,40 @@ +#!/bin/bash +TOKEN="" +CLOUD_URL="http://13.232.42.59" +INSTALL_DIR="$HOME/.autopilot/agent" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --token) TOKEN="$2"; shift ;; + *) echo "Unknown parameter passed: $1"; exit 1 ;; + esac + shift +done + +if [ -z "$TOKEN" ]; then + echo "Error: --token is required." + exit 1 +fi + +echo -e "\033[0;36m🚀 Installing AutoPilot Agent...\033[0m" +mkdir -p "$INSTALL_DIR" +cd "$INSTALL_DIR" + +echo -e "\033[0;36m📥 Downloading Agent JAR...\033[0m" +# In production, this points to the real hosted JAR +curl -sL "$CLOUD_URL/agent/localagent-java.jar" -o localagent-java.jar + +echo -e "\033[0;36m⚙️ Configuring Agent...\033[0m" +cat > application.yml < --token [--server ] [--timeout ]" + exit 1 +fi + +# Trim trailing slash from server URL +SERVER_URL="${SERVER_URL%/}" + +echo "==================================================" +echo "🚀 AutoPilot CI/CD Pipeline Gating" +echo "==================================================" +echo "Server: $SERVER_URL" +echo "Suite ID: $SUITE_ID" +echo "Timeout: ${TIMEOUT_SEC}s" +echo "==================================================" + +echo "Triggering suite execution..." +RESPONSE=$(curl -s -X POST "$SERVER_URL/api/v1/suites/$SUITE_ID/trigger" \ + -H "Authorization: Bearer $API_TOKEN" \ + -H "Content-Type: application/json") + +if [ $? -ne 0 ]; then + echo "❌ Error: Failed to connect to AutoPilot server at $SERVER_URL" + exit 1 +fi + +# Extract executionId using grep/sed (no jq dependency) +EXECUTION_ID=$(echo "$RESPONSE" | grep -o '"executionId":[0-9]*' | head -n1 | cut -d':' -f2) + +if [ -z "$EXECUTION_ID" ]; then + echo "❌ Error: Failed to trigger suite execution. Server response:" + echo "$RESPONSE" + exit 1 +fi + +echo "✅ Triggered successfully! Execution ID: $EXECUTION_ID" +echo "Polling execution status..." + +START_TIME=$(date +%s) +PREV_STATUS="" + +while true; do + # Check timeout + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + if [ "$ELAPSED" -ge "$TIMEOUT_SEC" ]; then + echo "❌ Error: Timeout reached (${TIMEOUT_SEC}s). Gating failed." + exit 1 + fi + + # Query status + STATUS_RESP=$(curl -s "$SERVER_URL/api/v1/executions/$EXECUTION_ID/status" \ + -H "Authorization: Bearer $API_TOKEN") + + # Extract status, passedCount, failedCount, totalCount + STATUS=$(echo "$STATUS_RESP" | grep -o '"status":"[^"]*"' | head -n1 | cut -d':' -f2 | tr -d '"') + PASSED=$(echo "$STATUS_RESP" | grep -o '"passedCount":[0-9]*' | head -n1 | cut -d':' -f2) + FAILED=$(echo "$STATUS_RESP" | grep -o '"failedCount":[0-9]*' | head -n1 | cut -d':' -f2) + TOTAL=$(echo "$STATUS_RESP" | grep -o '"totalCount":[0-9]*' | head -n1 | cut -d':' -f2) + + if [ -z "$STATUS" ]; then + echo "⚠️ Warning: Failed to parse status. Retrying..." + sleep 5 + continue + fi + + # Print status updates on change + STATUS_LINE="Status: $STATUS (Passed: ${PASSED:-0} | Failed: ${FAILED:-0} | Total: ${TOTAL:-0})" + if [ "$STATUS" != "$PREV_STATUS" ]; then + echo "[$(date +%T)] $STATUS_LINE" + PREV_STATUS="$STATUS" + fi + + # Check terminal status + if [ "$STATUS" = "PASSED" ] || [ "$STATUS" = "SUCCESS" ] || [ "$STATUS" = "COMPLETED" ]; then + echo "==================================================" + echo "✅ Pipeline Gating: SUCCESS!" + echo "All tests passed. Proceeding with deployment." + echo "==================================================" + exit 0 + elif [ "$STATUS" = "FAILED" ]; then + echo "==================================================" + echo "❌ Pipeline Gating: FAILED!" + echo "$FAILED tests failed. Deployment blocked." + echo "==================================================" + exit 1 + elif [ "$STATUS" = "ERROR" ] || [ "$STATUS" = "CANCELLED" ]; then + echo "==================================================" + echo "❌ Pipeline Gating: ERROR!" + echo "Execution finished with status: $STATUS. Deployment blocked." + echo "==================================================" + exit 1 + fi + + sleep 5 +done diff --git a/master/src/test/java/com/autopropel/localagent_cloud/AgentControllerTests.java b/master/src/test/java/com/autopropel/localagent_cloud/AgentControllerTests.java new file mode 100644 index 0000000..4c5623c --- /dev/null +++ b/master/src/test/java/com/autopropel/localagent_cloud/AgentControllerTests.java @@ -0,0 +1,87 @@ +package com.autopilot.localagent_cloud; + +import java.util.ArrayList; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.autopilot.localagent_cloud.dto.RunRequest; +import com.autopilot.localagent_cloud.dto.RunResult; +import com.autopilot.localagent_cloud.model.Agent; +import com.fasterxml.jackson.databind.ObjectMapper; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public class AgentControllerTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + public void testMvpFlow() throws Exception { + // 1. Register agent + Agent agent = new Agent(); + agent.setId("agent_test_01"); + agent.setName("Test Agent"); + agent.setOs("Linux"); + agent.setAgentVersion("1.0"); + + mockMvc.perform(post("/agents/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(agent))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("agent_test_01")) + .andExpect(jsonPath("$.name").value("Test Agent")); + + // 2. Heartbeat + mockMvc.perform(post("/agents/agent_test_01/heartbeat")) + .andExpect(status().isOk()); + + // 3. Create Execution & Job + RunRequest runRequest = new RunRequest(); + runRequest.result = new RunResult(); + runRequest.result.referenceId = "mvp_run_789"; + runRequest.result.environmentId = "agent_test_01"; + runRequest.result.iterationval = "iteration1"; + runRequest.result.testCase = new ArrayList<>(); + runRequest.result.testCase.add(Map.of("iteration1", new ArrayList<>())); + + MvcResult execResult = mockMvc.perform(post("/executions?agentId=agent_test_01") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(runRequest))) + .andExpect(status().isOk()) + .andReturn(); + + String execIdStr = execResult.getResponse().getContentAsString(); + Long execId = Long.parseLong(execIdStr); + + // 4. Lease next job + mockMvc.perform(get("/agents/agent_test_01/jobs/next")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.agentId").value("agent_test_01")) + .andExpect(jsonPath("$.status").value("ASSIGNED")); + + // 5. Submit execution results + runRequest.result.result_status = 1; // SUCCESS + mockMvc.perform(post("/executions/" + execId + "/results") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(runRequest))) + .andExpect(status().isOk()); + } +} diff --git a/master/src/test/resources/application-test.yml b/master/src/test/resources/application-test.yml new file mode 100644 index 0000000..84b89e0 --- /dev/null +++ b/master/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource: + url: jdbc:h2:mem:localagent-cloud-test;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + properties: + hibernate: + format_sql: true + show-sql: false + flyway: + enabled: false diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/ui/Dockerfile b/ui/Dockerfile new file mode 100644 index 0000000..bb945ed --- /dev/null +++ b/ui/Dockerfile @@ -0,0 +1,16 @@ +# Stage 1: Build React App +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . ./ +RUN npm run build + +# Stage 2: Serve with Nginx +FROM nginx:alpine +# Copy the built files from Vite's default output directory 'dist' +COPY --from=build /app/dist /usr/share/nginx/html +# Copy custom Nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..f968994 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,79 @@ +# AutoPilot Platform + +AutoPilot is a high-performance web browser automation platform designed for distributed execution orchestration. It consists of three core components: + +--- + +## 📂 Project Structure + +```text +localagent/ +├── autopilot-ui/ # React/Vite Frontend (Web Dashboard) +├── autopilot-master/ # Cloud Coordinator Broker service (Spring Boot) +├── autopilot-worker/ # AutoPropel LocalAgent application runner (Spring Boot) +├── service-packaging/ # Windows Service wrappers and registration scripts +├── docker-compose.yml # Local development database infrastructure +└── pom.xml # Workspace parent POM for the Java projects +``` + +--- + +## 🛠️ Prerequisites +- **Java**: JDK 21 or JDK 25 installed and configured on your `PATH`. +- **Node.js**: v18+ (for running the React UI). +- **Browsers**: Google Chrome and/or Mozilla Firefox installed in default system paths. +- **Docker**: For running the local PostgreSQL database. + +--- + +## 🏗️ Local Development Setup + +To run the entire distributed platform on your local machine, follow these steps in order: + +### 1. Start the Local Database +We use Docker to spin up a local PostgreSQL database for development, so you don't need to connect to AWS. +From the root of the project: +```bash +docker-compose up -d +``` +*(This starts PostgreSQL on port `5432` with credentials `user: localagent`, `password: localagent`)* + +### 2. Start the Master (Cloud Coordinator) +The Master broker manages the database, serves the API, and schedules jobs. +Open a new terminal at the root of the project: +```bash +.\mvnw.cmd clean spring-boot:run -pl autopilot-master -Dspring-boot.run.profiles=default -DJWT_SECRET=supersecretjwtkeythatismorethan256bits +``` +*(The Master will start on port `9090`)* + +### 3. Start the UI (React Frontend) +The frontend dashboard allows you to visually interact with the platform. +Open a new terminal at the root of the project: +```bash +cd autopilot-ui +npm install +npm run dev +``` +*(The UI will start on port `5173` and proxy API requests to the Master on `9090`)* + +### 4. Start the Worker (Execution Engine) +The Worker actually drives the browser and runs the tests. It will automatically connect to your local Master to fetch jobs. +Open a new terminal at the root of the project: +```bash +.\mvnw.cmd clean spring-boot:run -pl autopilot-worker/localagent-java +``` +*(The Worker will start on port `8080` and begin polling `http://localhost:9090` for jobs)* + +--- + +## ☁️ Cloud / Production Deployment +When deploying to AWS or another cloud provider: +1. Deploy `autopilot-master` to an EC2 instance or ECS container and point it to a managed PostgreSQL RDS database using `DB_URL`, `DB_USER`, and `DB_PASS`. +2. Build and deploy `autopilot-ui` (e.g. via S3 + CloudFront or an Nginx container). +3. Distribute the `autopilot-worker` to the testing nodes. Set the `LOCALAGENT_CLOUD_URL` environment variable on the workers to point to your public Master API. + +Example for Worker: +```bash +set LOCALAGENT_CLOUD_URL=https://api.yourdomain.com +set LOCALAGENT_AGENT_ID=agent_windows_01 +``` diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 0000000..663bf49 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..ea36dd3 --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,21 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + }, +]) diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..19d4492 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + AutoPilot + + +
+ + + diff --git a/ui/jsconfig.json b/ui/jsconfig.json new file mode 100644 index 0000000..178ee63 --- /dev/null +++ b/ui/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "ignoreDeprecations": "6.0", + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/ui/nginx.conf b/ui/nginx.conf new file mode 100644 index 0000000..a50c4c1 --- /dev/null +++ b/ui/nginx.conf @@ -0,0 +1,30 @@ +server { + listen 80; + server_name localhost; + + # Allow large request bodies (screenshots are base64 encoded) + client_max_body_size 50M; + + # Serve static files from the React build + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } + + # Reverse proxy API requests to the Master backend container + location /api/ { + proxy_pass http://master:9090; + + # Standard proxy headers + 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; + + # Increase timeouts for long-running execution result uploads + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_connect_timeout 30s; + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..5d7dc51 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2887 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/geist": "^5.2.9", + "@tailwindcss/vite": "^4.3.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "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==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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": "1.17.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", + "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-router": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz", + "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.15.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz", + "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==", + "license": "MIT", + "dependencies": { + "react-router": "7.15.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/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/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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "license": "MIT", + "peer": true + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..2a5217e --- /dev/null +++ b/ui/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/geist": "^5.2.9", + "@tailwindcss/vite": "^4.3.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.17.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-router-dom": "^7.15.1", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.1", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "vite": "^8.0.12" + } +} diff --git a/ui/public/agent/AutopilotAgent-1.0.0.msi b/ui/public/agent/AutopilotAgent-1.0.0.msi new file mode 100644 index 0000000..2fed274 Binary files /dev/null and b/ui/public/agent/AutopilotAgent-1.0.0.msi differ diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/icons.svg b/ui/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/ui/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/public/logo.png b/ui/public/logo.png new file mode 100644 index 0000000..6d546d2 Binary files /dev/null and b/ui/public/logo.png differ diff --git a/ui/src/App.css b/ui/src/App.css new file mode 100644 index 0000000..b17a15f --- /dev/null +++ b/ui/src/App.css @@ -0,0 +1,2482 @@ +/* ──────────────────────────────────────────────────────────────────────────────────────────────────── + AutoPilot — Complete Premium Design System + ──────────────────────────────────────────────────────────────────────────────────────────────────── */ + +/* ──── Google Fonts ──── */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); + +/* ──── Design Tokens ──── */ +/* ──── Design Tokens — Dark Theme (default) ──── */ +:root, +:root[data-theme="dark"] { + --brand: #7c3aed; + --brand-light: #a78bfa; + --brand-dark: #5b21b6; + --brand-glow: rgba(124,58,237,0.20); + --brand-subtle: rgba(124,58,237,0.08); + + --sb-w: 260px; + --sb-bg: #141414; + --sb-border: #262626; + --sb-txt: #a1a1aa; + --sb-hover-bg: #1e1e1e; + --sb-hover-txt: #fafafa; + --sb-active-bg: rgba(124,58,237,0.12); + --sb-active-txt: #c4b5fd; + --sb-active-bar: #7c3aed; + + --bg: #131316; + --surface: #1c1c21; + --surface-2: #232329; + --border: #2d2d36; + --border-hover: #40404a; + + --txt-h: #fafafa; + --txt: #a1a1aa; + --txt-muted: #71717a; + + --green: #10b981; + --green-bg: rgba(16,185,129,0.10); + --green-txt: #34d399; + --red: #ef4444; + --red-bg: rgba(239,68,68,0.10); + --red-txt: #fca5a5; + --yellow: #f59e0b; + --yellow-bg: rgba(245,158,11,0.10); + --yellow-txt: #fcd34d; + --blue: #3b82f6; + --blue-bg: rgba(59,130,246,0.10); + --blue-txt: #93c5fd; + --indigo: #6366f1; + --indigo-bg: rgba(99,102,241,0.10); + --indigo-txt: #a5b4fc; + + --r-xs: 3px; --r-sm: 5px; --r-md: 8px; --r-lg: 10px; --r-xl: 12px; --r-2xl: 14px; + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.25); + --shadow-sm: 0 2px 6px rgba(0,0,0,0.30); + --shadow-md: 0 4px 12px rgba(0,0,0,0.35); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.40); + --shadow-xl: 0 16px 40px rgba(0,0,0,0.50); + + --t: all 0.15s ease; + --t-slow: all 0.25s ease; + --t-spring: all 0.3s cubic-bezier(0.34,1.56,0.64,1); + + --auth-bg: #09090b; + --auth-card-bg: #141414; + --auth-border: #262626; + + --header-bg: rgba(20,20,20,0.85); + + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 14px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + color: var(--txt); +} + +/* ──── Light Theme Overrides ──── */ +:root[data-theme="light"] { + --sb-bg: #e3e5e8; + --sb-border: #cbd5e1; + --sb-txt: #52525b; + --sb-hover-bg: #f4f4f5; + --sb-hover-txt: #18181b; + --sb-active-bg: rgba(124,58,237,0.08); + --sb-active-txt: #7c3aed; + + --bg: #e3e5e8; + --surface: #ffffff; + --surface-2: #f4f4f5; + --border: #cbd5e1; + --border-hover: #94a3b8; + + --txt-h: #18181b; + --txt: #52525b; + --txt-muted: #a1a1aa; + + --green: #059669; + --green-bg: #ecfdf5; + --green-txt: #065f46; + --red: #dc2626; + --red-bg: #fef2f2; + --red-txt: #991b1b; + --yellow: #d97706; + --yellow-bg: #fffbeb; + --yellow-txt: #92400e; + --blue: #2563eb; + --blue-bg: #eff6ff; + --blue-txt: #1e40af; + --indigo: #4f46e5; + --indigo-bg: #eef2ff; + --indigo-txt: #4338ca; + + --shadow-xs: 0 1px 2px rgba(0,0,0,0.04); + --shadow-sm: 0 1px 4px rgba(0,0,0,0.06); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + --shadow-lg: 0 8px 24px rgba(0,0,0,0.10); + --shadow-xl: 0 16px 40px rgba(0,0,0,0.12); + + --auth-bg: #fafafa; + --auth-card-bg: #ffffff; + --auth-border: #e4e4e7; + --header-bg: rgba(227,229,232,0.90); +} + +/* Light theme header fix */ +:root[data-theme="light"] .top-header { background: var(--header-bg); } + + +*, *::before, *::after { margin:0; padding:0; box-sizing:border-box; } +body { background: var(--bg); color: var(--txt); } +a { text-decoration: none; } +button { font-family: inherit; cursor: pointer; } +input, select, textarea { font-family: inherit; } + +/* ───────────────────────────────────────── + APP SHELL +───────────────────────────────────────── */ +.app-layout { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* ───────────────────────────────────────── + SIDEBAR +───────────────────────────────────────── */ +.sidebar { + width: var(--sb-w); + flex-shrink: 0; + height: 100%; + background: var(--sb-bg); + display: flex; + flex-direction: column; + border-right: 1px solid var(--sb-border); + overflow: hidden; + position: relative; + z-index: 20; +} + +/* Subtle gradient glow at top */ +.sidebar::before { + display: none; +} + +.sidebar-header { + padding: 24px 20px 20px; + border-bottom: 1px solid var(--sb-border); + flex-shrink: 0; + position: relative; +} + +.logo { + display: flex; + align-items: center; + gap: 10px; +} + +.logo-icon { + width: 34px; + height: 34px; + background: transparent; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.logo-icon img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: inherit; +} + +@keyframes logoPulse { + 0%,100% { opacity: 1; } + 50% { opacity: 0.8; } +} + +.logo-text { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 18px; + font-weight: 800; + color: var(--txt-h); + letter-spacing: -0.5px; +} + +.logo-text span { + color: var(--brand-light); +} + +.sidebar-nav { + flex: 1; + padding: 16px 12px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.06) transparent; +} + +.sidebar-nav::-webkit-scrollbar { width: 4px; } +.sidebar-nav::-webkit-scrollbar-track { background: transparent; } +.sidebar-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 4px; } + +.nav-section { + font-size: 10px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; + color: var(--sb-txt); + opacity: 0.7; + padding: 18px 10px 7px; + display: flex; + align-items: center; + gap: 8px; +} + +.nav-section::after { + content: ''; + flex: 1; + height: 1px; + background: var(--sb-border); +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + color: var(--sb-txt); + border-radius: var(--r-md); + margin-bottom: 2px; + font-size: 13.5px; + font-weight: 500; + transition: var(--t); + position: relative; + cursor: pointer; +} + +.nav-item:hover { + background: var(--sb-hover-bg); + color: var(--sb-hover-txt); +} + +.nav-item.active { + background: var(--sb-active-bg); + color: var(--sb-active-txt); + font-weight: 600; +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: 0; top: 50%; + transform: translateY(-50%); + width: 3px; height: 20px; + background: var(--brand-light); + border-radius: 0 3px 3px 0; +} + +.nav-icon-wrap { + width: 28px; + height: 28px; + border-radius: var(--r-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; + transition: var(--t); +} + +.nav-item:hover .nav-icon-wrap, +.nav-item.active .nav-icon-wrap { + background: rgba(124,58,237,0.18); +} + +.sidebar-footer { + padding: 16px 20px; + border-top: 1px solid var(--sb-border); + flex-shrink: 0; +} + +.sidebar-footer-inner { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: rgba(255,255,255,0.03); + border-radius: var(--r-md); + border: 1px solid var(--sb-border); +} + +.footer-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--green); + flex-shrink: 0; + animation: blinkGreen 2s ease-in-out infinite; +} + +@keyframes blinkGreen { + 0%,100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.footer-text { + flex: 1; +} + +.footer-text p { + font-size: 11.5px; + color: rgba(255,255,255,0.65); + line-height: 1.4; +} + +.footer-text span { + font-size: 10px; + color: rgba(255,255,255,0.40); +} + +/* ───────────────────────────────────────── + MAIN CONTENT +───────────────────────────────────────── */ +.main-content { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + min-width: 0; +} + +/* ───────────────────────────────────────── + TOP HEADER +───────────────────────────────────────── */ +.top-header { + height: 64px; + background: var(--header-bg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 32px; + flex-shrink: 0; + position: sticky; + top: 0; + z-index: 10; +} + +.menu-toggle { + width: 36px; height: 36px; + border: 1px solid var(--border); + background: var(--surface); + border-radius: var(--r-sm); + font-size: 16px; + color: var(--txt); + display: flex; + align-items: center; + justify-content: center; + transition: var(--t); +} + +.menu-toggle:hover { + background: var(--bg); + border-color: var(--border-hover); +} + +.header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.header-icon-btn { + position: relative; + width: 38px; height: 38px; + background: none; + border: 1px solid var(--border); + border-radius: var(--r-sm); + font-size: 17px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--t); + color: var(--txt); + background: var(--surface); +} + +.header-icon-btn:hover { + background: var(--bg); + border-color: var(--border-hover); +} + +.header-badge { + position: absolute; + top: -4px; right: -4px; + min-width: 16px; height: 16px; + padding: 0 4px; + border-radius: 8px; + font-size: 9px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid #fff; + background: var(--brand); + color: #fff; +} + +.header-badge.warn { background: var(--yellow); } + +.header-divider { + width: 1px; + height: 32px; + background: var(--border); + margin: 0 4px; +} + +.header-avatar { + width: 36px; height: 36px; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--brand-light); + cursor: pointer; + transition: var(--t); + display: flex; + align-items: center; + justify-content: center; + background: var(--brand); + color: #fff; + font-weight: 700; + font-size: 16px; +} + +.header-avatar:hover { opacity: 0.85; box-shadow: 0 0 10px var(--brand-glow); } +.header-avatar img { width:100%; height:100%; object-fit:cover; } + +.header-user-pill { + display: flex; + align-items: center; + gap: 12px; + background: var(--surface-2); + padding: 4px 6px 4px 4px; + border-radius: 30px; + border: 1px solid var(--border); + transition: var(--t); +} + +.header-user-pill:hover { + background: var(--surface); + border-color: var(--border-hover); +} + +.header-user-name { + font-size: 13.5px; + font-weight: 600; + color: var(--txt-h); + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.header-logout-btn { + background: transparent; + border: none; + color: var(--txt-muted); + font-size: 18px; + padding: 4px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: var(--t); + margin-right: 4px; +} + +.header-logout-btn:hover { + background: var(--red-bg); + color: var(--red-txt); +} + +/* ───────────────────────────────────────── + PAGE CONTAINER & HEADER +───────────────────────────────────────── */ +.page-container { + flex: 1; + overflow-y: auto; + padding: 32px; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.page-container::-webkit-scrollbar { width: 6px; } +.page-container::-webkit-scrollbar-track { background: transparent; } +.page-container::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } + +.page-view { animation: pageIn 0.3s ease; } + +@keyframes pageIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ─── Dashboard Priority Grid ────────────────────────────────────────────── + A 2-column layout. Full-width items use gridColumn: 1 / -1. + minmax(0, 1fr) prevents content from blowing out the grid. + Collapses gracefully to 1 column on narrower screens. +────────────────────────────────────────────────────────────────────────── */ +.dashboard-priority-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px; + align-items: start; +} + +.dashboard-priority-grid > * { + min-width: 0; + overflow: hidden; +} + +@media (max-width: 1100px) { + .dashboard-priority-grid { + grid-template-columns: 1fr; + } + .dashboard-priority-grid > * { + grid-column: 1 / -1 !important; + } +} + +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 28px; + gap: 16px; +} + +.page-header-left h1 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 26px; + font-weight: 800; + color: var(--txt-h); + letter-spacing: -0.5px; + line-height: 1.2; +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 6px; + margin-top: 5px; + font-size: 12.5px; + color: var(--txt-muted); +} + +.breadcrumbs a, .breadcrumbs span { + color: var(--txt-muted); + transition: var(--t); +} + +.breadcrumbs a:hover { color: var(--brand); } + +.breadcrumbs .sep { color: var(--border-hover); } + +.page-header-actions { + display: flex; + gap: 10px; + flex-shrink: 0; +} + +/* ───────────────────────────────────────── + BUTTONS +───────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 9px 18px; + font-size: 13px; + font-weight: 600; + border-radius: var(--r-md); + border: 1px solid transparent; + cursor: pointer; + transition: var(--t); + white-space: nowrap; +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.btn-primary { + background: var(--brand); + color: #fff; + border-color: var(--brand-dark); + box-shadow: 0 2px 8px var(--brand-glow); +} + +.btn-primary:hover:not(:disabled) { + background: var(--brand-dark); + transform: translateY(-1px); + box-shadow: 0 4px 14px var(--brand-glow); +} + +.btn-success { + background: var(--green); + color: #fff; + border-color: #047857; + box-shadow: 0 2px 8px rgba(5,150,105,0.25); +} + +.btn-success:hover:not(:disabled) { + background: #047857; + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(5,150,105,0.35); +} + +.btn-danger { + background: var(--red); + color: #fff; + border-color: #b91c1c; +} + +.btn-danger:hover:not(:disabled) { + background: #b91c1c; + transform: translateY(-1px); +} + +.btn-ghost { + background: transparent; + color: var(--txt); + border-color: var(--border); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bg); + border-color: var(--border-hover); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; + border-radius: var(--r-sm); +} + +.btn-icon { + width: 32px; height: 32px; + padding: 0; + border-radius: var(--r-sm); +} + +/* ───────────────────────────────────────── + CARDS +───────────────────────────────────────── */ +.card { + background: var(--surface); + border-radius: var(--r-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: var(--t-slow); +} + +.card-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.card-header h2 { + font-size: 15px; + font-weight: 700; + color: var(--txt-h); + letter-spacing: -0.2px; +} + +.card-header p { + font-size: 12.5px; + color: var(--txt-muted); + margin-top: 2px; +} + +/* ───────────────────────────────────────── + STAT CARDS (dashboard) +───────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--surface); + border-radius: var(--r-lg); + border: 1px solid var(--border); + padding: 20px; + display: flex; + align-items: flex-start; + gap: 14px; + transition: var(--t-slow); + cursor: default; + position: relative; + overflow: hidden; +} + +.stat-card::after { + content: ''; + position: absolute; + top: 0; right: 0; + width: 80px; height: 80px; + border-radius: 50%; + opacity: 0.06; + transform: translate(20px, -20px); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--border-hover); +} + +.stat-card.green::after { background: var(--green); } +.stat-card.blue::after { background: var(--blue); } +.stat-card.purple::after { background: var(--brand); } +.stat-card.yellow::after { background: var(--yellow); } + +.stat-icon { + width: 44px; height: 44px; + border-radius: var(--r-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; +} + +.stat-icon.green { background: var(--green-bg); } +.stat-icon.blue { background: var(--blue-bg); } +.stat-icon.purple { background: var(--indigo-bg); } +.stat-icon.yellow { background: var(--yellow-bg); } + +.stat-body { flex: 1; min-width: 0; } + +.stat-value { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 26px; + font-weight: 800; + color: var(--txt-h); + line-height: 1; + margin-bottom: 5px; +} + +.stat-label { + font-size: 12.5px; + color: var(--txt-muted); + font-weight: 500; +} + +.stat-trend { + font-size: 11px; + font-weight: 600; + display: inline-flex; + align-items: center; + gap: 3px; + margin-top: 6px; + padding: 2px 8px; + border-radius: 20px; +} + +.stat-trend.up { background: var(--green-bg); color: var(--green-txt); } +.stat-trend.down { background: var(--red-bg); color: var(--red-txt); } +.stat-trend.neu { background: var(--bg); color: var(--txt-muted); } + +/* ───────────────────────────────────────── + TABLE SYSTEM +───────────────────────────────────────── */ +.table-toolbar { + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} + +.toolbar-left, .toolbar-right { + display: flex; + align-items: center; + gap: 10px; +} + +.entries-select-wrap { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--txt-muted); +} + +.entries-select-wrap select { + padding: 6px 10px; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface); + font-size: 13px; + color: var(--txt-h); + outline: none; + cursor: pointer; + transition: var(--t); +} + +.entries-select-wrap select:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-glow); +} + +.search-wrap { + position: relative; + display: flex; + align-items: center; +} + +.search-wrap .search-icon { + position: absolute; + left: 10px; + font-size: 14px; + color: var(--txt-muted); + pointer-events: none; +} + +.search-input { + padding: 8px 12px 8px 34px; + border-radius: var(--r-md); + border: 1px solid var(--border); + background: var(--surface); + font-size: 13px; + color: var(--txt-h); + outline: none; + width: 220px; + transition: var(--t); +} + +.search-input:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-glow); + width: 260px; +} + +.search-input::placeholder { color: var(--txt-muted); } + +.table-responsive { overflow-x: auto; width: 100%; } + +.data-table { + width: 100%; + border-collapse: collapse; +} + +.data-table th { + padding: 12px 20px; + text-align: left; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.8px; + text-transform: uppercase; + color: var(--txt-muted); + background: var(--surface-2); + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.data-table td { + padding: 14px 20px; + font-size: 13.5px; + color: var(--txt); + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.data-table tbody tr { + transition: var(--t); +} + +.data-table tbody tr:hover { + background: rgba(124,58,237,0.02); +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr.row-expanded { + background: rgba(124,58,237,0.03); +} + +.cell-bold { font-weight: 600; color: var(--txt-h); } +.cell-mono { + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 12px; + background: var(--bg); + padding: 3px 8px; + border-radius: var(--r-xs); + color: var(--brand); +} + +/* Empty / Loading row */ +.row-empty td, .row-loading td { + text-align: center; + padding: 56px 20px; +} + +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.empty-state-icon { + width: 64px; height: 64px; + border-radius: 50%; + background: var(--bg); + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + color: var(--txt-muted); +} + +.empty-state h3 { + font-size: 15px; + font-weight: 600; + color: var(--txt-h); +} + +.empty-state p { + font-size: 13px; + color: var(--txt-muted); +} + +/* Loading spinner */ +.spinner { + width: 28px; height: 28px; + border: 3px solid var(--border); + border-top-color: var(--brand); + border-radius: 50%; + animation: spin 0.7s linear infinite; + margin: 0 auto; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +/* Pagination */ +.table-footer { + padding: 14px 24px; + display: flex; + align-items: center; + justify-content: space-between; + border-top: 1px solid var(--border); + background: var(--surface-2); +} + +.pag-info { font-size: 12.5px; color: var(--txt-muted); } + +.pag-btns { display: flex; gap: 6px; } + +.pag-btn { + min-width: 32px; height: 32px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: var(--r-sm); + background: var(--surface); + font-size: 12.5px; + font-weight: 500; + color: var(--txt); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--t); +} + +.pag-btn:hover:not(:disabled) { background: var(--bg); border-color: var(--border-hover); } +.pag-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.pag-btn.active { background: var(--brand); color: #fff; border-color: var(--brand-dark); } + +/* ───────────────────────────────────────── + STATUS BADGES +───────────────────────────────────────── */ +.badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 10px; + border-radius: 20px; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; +} + +.badge::before { + content: ''; + width: 6px; height: 6px; + border-radius: 50%; + flex-shrink: 0; +} + +.badge-success { background: var(--green-bg); color: var(--green-txt); } +.badge-success::before { background: var(--green); } +.badge-danger { background: var(--red-bg); color: var(--red-txt); } +.badge-danger::before { background: var(--red); } +.badge-warning { background: var(--yellow-bg); color: var(--yellow-txt); } +.badge-warning::before { background: var(--yellow); } +.badge-info { background: var(--blue-bg); color: var(--blue-txt); } +.badge-info::before { background: var(--blue); } +.badge-purple { background: var(--indigo-bg); color: var(--indigo-txt); } +.badge-purple::before { background: var(--indigo); } +.badge-neutral { background: var(--bg); color: var(--txt-muted); } +.badge-neutral::before { background: var(--txt-muted); } + +/* ───────────────────────────────────────── + ACTION BUTTONS (table) +───────────────────────────────────────── */ +.action-row { display: flex; align-items: center; gap: 6px; } + +.act-btn { + width: 30px; height: 30px; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface); + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + cursor: pointer; + transition: var(--t); + color: var(--txt-muted); +} + +.act-btn:hover { background: var(--bg); border-color: var(--border-hover); color: var(--txt-h); transform: scale(1.05); } +.act-btn.view:hover { background: var(--blue-bg); border-color: var(--blue); color: var(--blue); } +.act-btn.delete:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); } + +/* ───────────────────────────────────────── + FORMS +───────────────────────────────────────── */ +.form-card { + max-width: 720px; +} + +.form-card-wide { + max-width: 1080px; +} + +.form-section { + padding: 24px; + border-bottom: 1px solid var(--border); +} + +.form-section:last-child { border-bottom: none; } + +.form-section-title { + font-size: 13px; + font-weight: 700; + color: var(--txt-h); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-bottom: 18px; + display: flex; + align-items: center; + gap: 8px; +} + +.form-section-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.form-grid.cols-1 { grid-template-columns: 1fr; } +.form-grid.cols-3 { grid-template-columns: 1fr 1fr 1fr; } + +.form-group { + display: flex; + flex-direction: column; + gap: 7px; +} + +.form-group.full { grid-column: 1 / -1; } + +.form-label { + font-size: 12.5px; + font-weight: 600; + color: var(--txt-h); + display: flex; + align-items: center; + gap: 5px; +} + +.form-label .req { color: var(--red); } + +.form-input, +.form-select, +.form-textarea { + width: 100%; + padding: 10px 14px; + border-radius: var(--r-md); + border: 1.5px solid var(--border); + background: var(--surface); + font-size: 13.5px; + color: var(--txt-h); + outline: none; + transition: var(--t); + font-family: inherit; +} + +.form-input::placeholder, +.form-textarea::placeholder { color: var(--txt-muted); } + +.form-input:hover, +.form-select:hover, +.form-textarea:hover { border-color: var(--border-hover); } + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-glow); +} + +.form-select { cursor: pointer; appearance: none; background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239898b0' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 12px center; padding-right: 36px; } + +.form-textarea { resize: vertical; min-height: 90px; } + +.form-hint { font-size: 12px; color: var(--txt-muted); } + +.form-actions { + padding: 20px 24px; + background: var(--surface-2); + border-top: 1px solid var(--border); + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* ───────────────────────────────────────── + STEP BUILDER +───────────────────────────────────────── */ +.step-builder { + padding: 20px 24px; + border-bottom: 1px solid var(--border); +} + +.step-builder-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.step-builder-title { + font-size: 13px; + font-weight: 700; + color: var(--txt-h); + display: flex; + align-items: center; + gap: 8px; +} + +.step-count-pill { + background: var(--indigo-bg); + color: var(--indigo-txt); + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; +} + +.step-list { display: flex; flex-direction: column; gap: 10px; } + +.step-card { + background: var(--bg); + border: 1.5px solid var(--border); + border-radius: var(--r-lg); + padding: 14px 16px; + display: flex; + align-items: flex-start; + gap: 12px; + transition: var(--t-slow); + animation: stepIn 0.25s ease; +} + +@keyframes stepIn { + from { opacity:0; transform: translateY(-6px) scale(0.98); } + to { opacity:1; transform: translateY(0) scale(1); } +} + +.step-card:hover { + border-color: var(--border-hover); + box-shadow: var(--shadow-xs); +} + +.step-num { + width: 28px; height: 28px; + border-radius: 50%; + background: linear-gradient(135deg, var(--brand), #a855f7); + color: #fff; + font-size: 12px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-top: 3px; + box-shadow: 0 2px 6px var(--brand-glow); +} + +.step-body { + flex: 1; + display: grid; + grid-template-columns: 1.2fr 1fr 1.4fr 1fr 1.6fr; + gap: 8px; +} + +.step-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.step-field-label { + font-size: 10px; + font-weight: 700; + color: var(--txt-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.step-field select, +.step-field input { + padding: 8px 10px; + border-radius: var(--r-sm); + border: 1.5px solid var(--border); + background: var(--surface); + font-size: 12.5px; + color: var(--txt-h); + outline: none; + width: 100%; + transition: var(--t); + font-family: inherit; +} + +.step-field select { appearance: none; cursor: pointer; } +.step-field select:focus, +.step-field input:focus { + border-color: var(--brand); + box-shadow: 0 0 0 3px var(--brand-glow); +} + +.step-remove-btn { + width: 28px; height: 28px; + border-radius: 50%; + border: 1.5px solid var(--border); + background: var(--surface); + color: var(--txt-muted); + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + cursor: pointer; + transition: var(--t); + margin-top: 3px; +} + +.step-remove-btn:hover:not(:disabled) { + background: var(--red-bg); + border-color: var(--red); + color: var(--red); + transform: rotate(90deg); +} + +.step-remove-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ───────────────────────────────────────── + CHECKBOX PICKER +───────────────────────────────────────── */ +.picker-wrap { + border: 1.5px solid var(--border); + border-radius: var(--r-lg); + overflow: hidden; + background: var(--surface); +} + +.picker-header { + padding: 12px 16px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.picker-header-left { + font-size: 12.5px; + color: var(--txt-muted); +} + +.picker-selected-count { + background: var(--brand); + color: #fff; + font-size: 11px; + font-weight: 700; + padding: 2px 8px; + border-radius: 20px; +} + +.picker-list { + max-height: 260px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.picker-item { + display: flex; + align-items: center; + gap: 12px; + padding: 13px 16px; + border-bottom: 1px solid var(--border); + cursor: pointer; + transition: var(--t); + user-select: none; +} + +.picker-item:last-child { border-bottom: none; } + +.picker-item:hover { background: var(--bg); } + +.picker-item.checked { background: var(--brand-subtle); } + +.picker-checkbox { + width: 18px; height: 18px; + border: 2px solid var(--border-hover); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: var(--t); + background: var(--surface); +} + +.picker-item.checked .picker-checkbox { + background: var(--brand); + border-color: var(--brand); +} + +.picker-checkmark { + color: #fff; + font-size: 11px; + font-weight: 700; + display: none; +} + +.picker-item.checked .picker-checkmark { display: block; } + +.picker-info { flex: 1; min-width: 0; } + +.picker-info strong { + font-size: 13.5px; + font-weight: 600; + color: var(--txt-h); + display: block; +} + +.picker-info small { + font-size: 12px; + color: var(--txt-muted); +} + +.picker-meta { + flex-shrink: 0; +} + +/* ───────────────────────────────────────── + EXPANDED ROW (detail panel) +───────────────────────────────────────── */ +.expand-panel td { + padding: 0 !important; + border-bottom: 2px solid var(--border-hover) !important; +} + +.expand-panel-inner { + padding: 20px 24px; + background: linear-gradient(135deg, var(--brand-subtle), rgba(124,58,237,0.03)); + border-top: 1px solid var(--border); + animation: expandIn 0.25s ease; +} + +@keyframes expandIn { + from { opacity:0; transform: translateY(-8px); } + to { opacity:1; transform: translateY(0); } +} + +.expand-panel-title { + font-size: 13px; + font-weight: 700; + color: var(--txt-h); + margin-bottom: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Nested table inside expand panel */ +.nested-table { + background: var(--surface); + border-radius: var(--r-md); + overflow: hidden; + border: 1px solid var(--border); + box-shadow: var(--shadow-xs); +} + +.nested-table th { padding: 10px 16px !important; font-size: 10.5px !important; } +.nested-table td { padding: 11px 16px !important; font-size: 12.5px !important; } + +/* Suite tree view */ +.suite-groups { display: flex; flex-direction: column; gap: 10px; } + +.suite-group-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-md); + overflow: hidden; + box-shadow: var(--shadow-xs); +} + +.suite-group-head { + padding: 11px 16px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 10px; +} + +.suite-group-num { + width: 24px; height: 24px; + border-radius: 6px; + background: var(--brand); + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; +} + +.suite-group-name { font-size: 13px; font-weight: 600; color: var(--txt-h); } + +.suite-group-badge { + margin-left: auto; + background: var(--bg); + border: 1px solid var(--border); + color: var(--txt-muted); + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 20px; +} + +.suite-case-row { + padding: 10px 16px 10px 44px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + font-size: 13px; +} + +.suite-case-row:last-child { border-bottom: none; } +.suite-case-order { color: var(--txt-muted); min-width: 18px; font-weight: 600; } +.suite-case-name { color: var(--txt-h); flex: 1; } +.suite-case-steps { + font-size: 11px; + background: var(--bg); + color: var(--txt-muted); + padding: 2px 7px; + border-radius: 12px; + border: 1px solid var(--border); +} + +/* ───────────────────────────────────────── + MODAL +───────────────────────────────────────── */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(10,10,20,0.5); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + z-index: 200; + display: flex; + align-items: center; + justify-content: center; + animation: backdropIn 0.2s ease; +} + +@keyframes backdropIn { + from { opacity:0; } + to { opacity:1; } +} + +.modal-box { + background: var(--surface); + border-radius: var(--r-xl); + width: 92%; + max-width: 980px; + max-height: 88vh; + box-shadow: var(--shadow-xl), 0 0 0 1px rgba(255,255,255,0.08); + display: flex; + flex-direction: column; + animation: modalIn 0.3s cubic-bezier(0.34,1.56,0.64,1); + overflow: hidden; +} + +@keyframes modalIn { + from { opacity:0; transform: scale(0.93) translateY(20px); } + to { opacity:1; transform: scale(1) translateY(0); } +} + +.modal-head { + padding: 22px 28px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.modal-head h3 { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 17px; + font-weight: 700; + color: var(--txt-h); +} + +.modal-close-btn { + width: 32px; height: 32px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + font-size: 18px; + color: var(--txt-muted); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--t); +} + +.modal-close-btn:hover { background: var(--red-bg); border-color: var(--red); color: var(--red); } + +.modal-body { + padding: 24px 28px; + overflow-y: auto; + flex: 1; +} + +/* Execution meta grid */ +.exec-meta { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.exec-meta-item { + background: var(--bg); + border-radius: var(--r-md); + padding: 14px 16px; + border: 1px solid var(--border); +} + +.exec-meta-label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.6px; + text-transform: uppercase; + color: var(--txt-muted); + margin-bottom: 6px; +} + +.exec-meta-val { + font-size: 14px; + font-weight: 600; + color: var(--txt-h); +} + +.steps-section h4 { + font-size: 14px; + font-weight: 700; + color: var(--txt-h); + margin-bottom: 14px; +} + +.screenshot-thumb { + width: 44px; + height: 34px; + object-fit: cover; + border-radius: var(--r-xs); + cursor: pointer; + border: 1px solid var(--border); + transition: var(--t); +} + +.screenshot-thumb:hover { transform: scale(1.15); box-shadow: var(--shadow-md); } + +/* ───────────────────────────────────────── + LIGHTBOX +───────────────────────────────────────── */ +.lightbox { + position: fixed; + inset: 0; + background: rgba(6,6,14,0.96); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + animation: backdropIn 0.2s ease; + cursor: zoom-out; +} + +.lightbox-img { + max-width: 90%; + max-height: 88vh; + border-radius: var(--r-lg); + box-shadow: var(--shadow-xl); + animation: modalIn 0.25s ease; +} + +.lightbox-close { + position: absolute; + top: 20px; right: 24px; + width: 40px; height: 40px; + border-radius: 50%; + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.15); + color: #fff; + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--t); +} + +.lightbox-close:hover { background: rgba(255,255,255,0.2); } + +/* ───────────────────────────────────────── + TOAST NOTIFICATIONS +───────────────────────────────────────── */ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 400; + display: flex; + flex-direction: column; + gap: 10px; + pointer-events: none; +} + +.toast { + background: var(--surface); + border-radius: var(--r-md); + padding: 14px 18px; + box-shadow: var(--shadow-xl); + border: 1px solid var(--border); + display: flex; + align-items: flex-start; + gap: 12px; + max-width: 360px; + pointer-events: auto; + animation: toastIn 0.35s cubic-bezier(0.34,1.56,0.64,1); +} + +.toast.hide { animation: toastOut 0.3s ease forwards; } + +@keyframes toastIn { + from { opacity:0; transform: translateX(60px) scale(0.95); } + to { opacity:1; transform: translateX(0) scale(1); } +} + +@keyframes toastOut { + to { opacity:0; transform: translateX(60px) scale(0.95); } +} + +.toast-icon { + width: 32px; height: 32px; + border-radius: var(--r-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.toast.success .toast-icon { background: var(--green-bg); } +.toast.error .toast-icon { background: var(--red-bg); } +.toast.info .toast-icon { background: var(--blue-bg); } + +.toast-body { flex: 1; } +.toast-title { font-size: 13.5px; font-weight: 700; color: var(--txt-h); } +.toast-msg { font-size: 12.5px; color: var(--txt-muted); margin-top: 2px; } + +/* ───────────────────────────────────────── + MISC UTILITIES +───────────────────────────────────────── */ +.action-tag { + font-family: 'Fira Code', 'Consolas', monospace; + font-size: 11.5px; + font-weight: 600; + background: var(--indigo-bg); + color: var(--indigo-txt); + padding: 3px 9px; + border-radius: var(--r-xs); +} + +.text-muted { color: var(--txt-muted); } +.text-sm { font-size: 12.5px; } + +.divider { height: 1px; background: var(--border); margin: 8px 0; } + +/* ───────────────────────────────────────── + CHARTS +───────────────────────────────────────── */ +.charts-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 24px; + animation: fadeIn 0.5s ease both; + animation-delay: 0.15s; +} + +.chart-card { min-height: 280px; } +.chart-body { padding: 24px; display: flex; justify-content: center; align-items: center; } + +/* Donut Chart */ +.donut-wrap { + display: flex; + align-items: center; + gap: 32px; + width: 100%; + justify-content: center; +} + +.donut-wrap svg { + transform: rotate(-90deg); + flex-shrink: 0; +} + +.donut-wrap svg text { + transform: rotate(90deg); + transform-origin: 100px 100px; +} + +.donut-legend { + display: flex; + flex-direction: column; + gap: 10px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--txt); +} + +.legend-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.legend-label { flex: 1; min-width: 50px; } +.legend-val { font-weight: 700; color: var(--txt-h); font-size: 14px; } + +.chart-empty { + color: var(--txt-muted); + font-size: 14px; + text-align: center; + padding: 40px; + font-style: italic; +} + +/* Bar Chart */ +.bar-chart { + display: flex; + align-items: flex-end; + gap: 12px; + width: 100%; + height: 180px; + padding: 0 8px; +} + +.bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + height: 100%; +} + +.bar-value { + font-size: 11px; + font-weight: 700; + color: var(--txt-h); + min-height: 18px; +} + +.bar-track { + flex: 1; + width: 100%; + max-width: 40px; + display: flex; + align-items: flex-end; + border-radius: var(--r-sm) var(--r-sm) 0 0; + overflow: hidden; + background: var(--surface-2); +} + +.bar-fill { + width: 100%; + border-radius: var(--r-sm) var(--r-sm) 0 0; + min-height: 3px; + transition: height 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + animation: barGrow 0.6s ease both; +} + +@keyframes barGrow { + from { height: 0 !important; } +} + +.bar-label { + font-size: 10px; + color: var(--txt-muted); + font-weight: 500; + text-align: center; + white-space: nowrap; +} + +/* Responsive */ +@media (max-width: 1200px) { + .stats-grid { grid-template-columns: repeat(2,1fr); } + .charts-row { grid-template-columns: 1fr; } +} + +@media (max-width: 900px) { + .step-body { grid-template-columns: 1fr 1fr; } + .form-grid { grid-template-columns: 1fr; } + .exec-meta { grid-template-columns: repeat(2,1fr); } + .donut-wrap { flex-direction: column; } +} + +@media (max-width: 600px) { + :root { --sb-w: 0px; } + .page-container { padding: 16px; } + .step-body { grid-template-columns: 1fr; } +} + +/* ───────────────────────────────────────── + OUTLOOK-STYLE SCHEDULER COMPONENTS +───────────────────────────────────────── */ + +/* Execution Type Toggle (Run Now / Scheduled) */ +.exec-type-toggle { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 24px; +} + +.exec-type-btn { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 20px 16px; + border-radius: var(--r-lg); + border: 2px solid var(--border); + background: var(--surface); + cursor: pointer; + transition: var(--t-spring); + text-align: center; + position: relative; + overflow: hidden; +} + +.exec-type-btn::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, var(--brand-subtle), transparent); + opacity: 0; + transition: var(--t); +} + +.exec-type-btn:hover { + border-color: var(--brand-light); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.exec-type-btn:hover::before { opacity: 1; } + +.exec-type-btn.active { + border-color: var(--brand); + background: var(--brand-subtle); + box-shadow: 0 0 0 3px var(--brand-glow), var(--shadow-md); + transform: translateY(-2px); +} + +.exec-type-btn.active::before { opacity: 1; } + +.exec-type-icon { + font-size: 28px; + line-height: 1; + transition: var(--t-spring); +} + +.exec-type-btn:hover .exec-type-icon, +.exec-type-btn.active .exec-type-icon { + transform: scale(1.15); +} + +.exec-type-label { + font-size: 15px; + font-weight: 700; + color: var(--txt-h); + font-family: 'Plus Jakarta Sans', sans-serif; +} + +.exec-type-sub { + font-size: 12px; + color: var(--txt-muted); + font-weight: 400; +} + +.exec-type-btn.active .exec-type-label { color: var(--brand); } + +/* Schedule Panel — appears when Scheduled is selected */ +.schedule-panel { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 24px; + margin-top: 4px; + animation: slideDown 0.25s cubic-bezier(0.4,0,0.2,1); + display: flex; + flex-direction: column; + gap: 20px; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +/* Date & Time Row */ +.datetime-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +/* Recurrence Cards */ +.recurrence-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 8px; +} + +.recurrence-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 10px; + border-radius: var(--r-md); + border: 2px solid var(--border); + background: var(--surface); + cursor: pointer; + transition: var(--t-spring); + text-align: center; +} + +.recurrence-card:hover { + border-color: var(--brand-light); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.recurrence-card.active { + border-color: var(--brand); + background: var(--brand-subtle); + box-shadow: 0 0 0 3px var(--brand-glow); + transform: translateY(-2px); +} + +.rc-icon { + font-size: 22px; + line-height: 1; + transition: var(--t-spring); +} + +.recurrence-card:hover .rc-icon, +.recurrence-card.active .rc-icon { transform: scale(1.2); } + +.rc-label { + font-size: 13px; + font-weight: 700; + color: var(--txt-h); +} + +.rc-sub { + font-size: 10.5px; + color: var(--txt-muted); + line-height: 1.3; +} + +.recurrence-card.active .rc-label { color: var(--brand); } + +/* Day of Week Pill Picker */ +.day-picker { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; +} + +.day-pill { + min-width: 46px; + height: 42px; + padding: 0 12px; + border-radius: 24px; + border: 2px solid var(--border); + background: var(--surface); + font-size: 13px; + font-weight: 600; + color: var(--txt); + cursor: pointer; + transition: var(--t-spring); + display: flex; + align-items: center; + justify-content: center; + user-select: none; +} + +.day-pill:hover { + border-color: var(--brand-light); + color: var(--brand); + transform: scale(1.06); + box-shadow: var(--shadow-sm); +} + +.day-pill.active { + background: var(--brand); + border-color: var(--brand-dark); + color: #fff; + box-shadow: 0 2px 10px var(--brand-glow); + transform: scale(1.06); +} + +/* Live Schedule Summary box */ +.schedule-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + background: linear-gradient(135deg, rgba(124,58,237,0.07), rgba(124,58,237,0.03)); + border: 1px solid rgba(124,58,237,0.20); + border-radius: var(--r-md); + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.schedule-summary-icon { + font-size: 20px; + flex-shrink: 0; +} + +.schedule-summary-text { + font-size: 13.5px; + font-weight: 600; + color: var(--brand); + line-height: 1.5; +} + +/* Schedule summary cell in the list table */ +.schedule-summary-cell { + font-size: 12.5px; + color: var(--txt-muted); + font-style: italic; + cursor: default; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 220px; + display: inline-block; +} + +/* Hint inline (inside label) */ +.form-hint-inline { + font-size: 11px; + color: var(--txt-muted); + font-weight: 400; + font-style: italic; +} + +/* Responsive adjustments */ +@media (max-width: 900px) { + .recurrence-cards { grid-template-columns: repeat(2, 1fr); } + .datetime-row { grid-template-columns: 1fr; } + .exec-type-toggle { grid-template-columns: 1fr; } +} + +@media (max-width: 600px) { + .recurrence-cards { grid-template-columns: repeat(2, 1fr); } + .day-picker { gap: 6px; } + .day-pill { min-width: 40px; height: 38px; font-size: 12px; } +} + +/* ═══════════════════════════════════════════════════════ + AUTH PAGES +═══════════════════════════════════════════════════════ */ +.auth-page { + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + background: var(--auth-bg); + padding: 20px; +} + +.auth-card { + background: var(--auth-card-bg); + border: 1px solid var(--auth-border); + border-radius: var(--r-xl); + padding: 48px 44px; + width: 100%; + max-width: 440px; + box-shadow: var(--shadow-xl); + backdrop-filter: blur(20px); +} + +.auth-logo { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 36px; +} + +.auth-logo-svg { + width: 40px; height: 40px; +} + +.auth-brand { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 22px; + font-weight: 800; + color: var(--txt-h); + letter-spacing: -0.5px; +} +.auth-brand span { color: var(--brand); } + +.auth-title { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 22px; + font-weight: 800; + text-align: center; + margin-bottom: 8px; + color: var(--txt-h); + letter-spacing: -0.3px; +} + +.auth-sub { + font-size: 14px; + color: var(--txt-muted); + text-align: center; + margin-bottom: 32px; + line-height: 1.6; +} + +.auth-error { + background: var(--red-bg); + color: var(--red-txt); + padding: 12px 16px; + border-radius: var(--r-md); + margin-bottom: 20px; + font-size: 13.5px; + font-weight: 500; + border: 1px solid rgba(220,38,38,0.2); + display: flex; + align-items: center; + gap: 8px; +} + +.auth-form .form-group { margin-bottom: 20px; } +.auth-btn { + width: 100%; + padding: 13px; + font-size: 15px; + margin-top: 8px; + border-radius: var(--r-md) !important; + letter-spacing: 0.2px; +} + +.auth-footer { + text-align: center; + margin-top: 28px; + font-size: 13.5px; + color: var(--txt-muted); +} +.auth-footer a { + color: var(--brand); + text-decoration: none; + font-weight: 600; +} +.auth-footer a:hover { text-decoration: underline; } + +.profile-dropdown { position: absolute; top: 100%; right: 0; margin-top: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 8px 0; min-width: 160px; z-index: 100; display: flex; flex-direction: column; } +.profile-dropdown-item { padding: 8px 16px; color: var(--txt); background: transparent; border: none; text-align: left; cursor: pointer; font-size: 14px; width: 100%; } +.profile-dropdown-item:hover { background: var(--border); } +.profile-dropdown-item.text-danger { color: #dc2626; } + +/* ───────────────────────────────────────── + MODAL STYLES (Variables & Environments) +───────────────────────────────────────── */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal-box { + background: var(--surface); + border-radius: var(--r-xl); + box-shadow: var(--shadow-xl); + width: 100%; + max-width: 500px; + overflow: hidden; + animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes modalIn { + from { opacity: 0; transform: scale(0.95) translateY(10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 700; + color: var(--txt-h); +} + +.modal-close { + background: transparent; + border: none; + font-size: 18px; + color: var(--txt-muted); + cursor: pointer; + padding: 4px; + border-radius: 50%; + transition: var(--t); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-close:hover { + background: var(--border); + color: var(--txt-h); +} + +.modal-body { + padding: 24px; +} + +.modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + background: var(--surface-2); +} + +/* ───────────────────────────────────────── + SKELETON LOADING +───────────────────────────────────────── */ +@keyframes shimmer { + 0% { background-position: -600px 0; } + 100% { background-position: 600px 0; } +} + +.skeleton { + background: linear-gradient(90deg, var(--border) 25%, var(--border-hover) 50%, var(--border) 75%); + background-size: 1200px 100%; + animation: shimmer 1.6s ease-in-out infinite; + border-radius: var(--r-sm); +} +.skeleton-text { height: 13px; margin-bottom: 8px; border-radius: 6px; } +.skeleton-text.lg { height: 22px; width: 45%; } +.skeleton-text.sm { height: 10px; width: 65%; } +.skeleton-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--r-xl); + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} +.skeleton-row { + height: 50px; + border-radius: var(--r-sm); + margin-bottom: 4px; +} +.skeleton-stat { + height: 90px; + border-radius: var(--r-lg); +} + +/* ───────────────────────────────────────── + SPLASH SCREEN +───────────────────────────────────────── */ +.splash-screen { + position: fixed; + inset: 0; + background: var(--bg); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} +.splash-screen.in { opacity: 0; } +.splash-screen.hold { opacity: 1; } +.splash-screen.out { opacity: 0; } + +.splash-inner { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} +.splash-logo-wrap { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 4px; +} +.splash-logo-icon { animation: splashPulse 1.6s ease-in-out infinite; } +@keyframes splashPulse { + 0%,100% { filter: drop-shadow(0 0 8px rgba(124,58,237,0.5)); } + 50% { filter: drop-shadow(0 0 24px rgba(124,58,237,0.95)); } +} +.splash-brand { + font-family: 'Plus Jakarta Sans', sans-serif; + font-size: 34px; + font-weight: 800; + color: var(--txt-h); + letter-spacing: -1px; +} +.splash-brand span { color: var(--brand); } +.splash-tagline { + font-size: 11px; + color: var(--txt-muted); + letter-spacing: 2.5px; + text-transform: uppercase; + font-weight: 500; +} + +/* ───────────────────────────────────────── + THEME TOGGLE BUTTON +───────────────────────────────────────── */ +.theme-toggle-btn { + width: 38px; height: 38px; + border-radius: var(--r-sm); + border: 1px solid var(--border); + background: var(--surface); + color: var(--txt); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: var(--t); + flex-shrink: 0; +} +.theme-toggle-btn:hover { + background: var(--brand-subtle); + border-color: var(--brand); + color: var(--brand); +} + +/* ───────────────────────────────────────── + HEADER THEME FIX +───────────────────────────────────────── */ +.top-header { + background: var(--header-bg) !important; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} diff --git a/ui/src/App.jsx b/ui/src/App.jsx new file mode 100644 index 0000000..3708698 --- /dev/null +++ b/ui/src/App.jsx @@ -0,0 +1,190 @@ +import { useState } from 'react'; +import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; +import './App.css'; + +import { AuthContext } from './context/AuthContext'; +import { ThemeProvider } from './context/ThemeContext'; +import { getUser } from './api/apiClient'; +import { ToastContainer } from './components/common/ToastContainer'; +import { SplashScreen } from './components/common/SplashScreen'; +import { PrivateRoute } from './components/common/PrivateRoute'; +import Sidebar from './components/layout/Sidebar'; +import Header from './components/layout/Header'; +import { OnboardingWizard } from './components/modals/OnboardingWizard'; +import { PairAgentModal } from './components/modals/PairAgentModal'; +import { InstallAgentModal } from './components/modals/InstallAgentModal'; +import { SessionExpiredModal } from './components/modals/SessionExpiredModal'; +import LandingPage from './pages/LandingPage'; +import LoginPage from './pages/auth/LoginPage'; +import RegisterPage from './pages/auth/RegisterPage'; +import ChangePasswordPage from './pages/auth/ChangePasswordPage'; +import DashboardView from './pages/dashboard/DashboardView'; +import TestCaseListView from './pages/testcase/TestCaseListView'; +import TestCaseFormView from './pages/testcase/TestCaseFormView'; +import TestStudioView from './pages/testcase/TestStudioView'; +import TestCaseGroupListView from './pages/testcasegroup/TestCaseGroupListView'; +import TestCaseGroupFormView from './pages/testcasegroup/TestCaseGroupFormView'; +import TestSuiteListView from './pages/testsuite/TestSuiteListView'; +import TestSuiteFormView from './pages/testsuite/TestSuiteFormView'; +import SchedulerListView from './pages/scheduler/SchedulerListView'; +import SchedulerFormView from './pages/scheduler/SchedulerFormView'; +import ExecutionDetailsView from './pages/execution/ExecutionDetailsView'; +import GroupsListView from './pages/agents/GroupsListView'; +import CreateGroupView from './pages/agents/CreateGroupView'; +import GroupDetailsView from './pages/agents/GroupDetailsView'; +import AgentListView from './pages/agents/AgentListView'; +import SettingsView from './pages/settings/SettingsView'; +import ApiKeysView from './pages/admin/ApiKeysView'; +import AuditLogsView from './pages/admin/AuditLogsView'; +import VariablesView from './pages/variables/VariablesView'; +import EnvironmentsView from './pages/environments/EnvironmentsView'; +import AnalyticsView from './pages/analytics/AnalyticsView'; +import ExecutionListView from './pages/execution/ExecutionListView'; +import DatasetsView from './pages/datasets/DatasetsView'; +import TestComponentListView from './pages/testcase/TestComponentListView'; +import CicdIntegrationView from './pages/cicd/CicdIntegrationView'; + +export default function App() { + const [sidebarOpen, setSidebarOpen] = useState(true); + const [lightbox, setLightbox] = useState(null); + const [user, setUser] = useState(getUser); + const [profileOpen, setProfileOpen] = useState(false); + const [showOnboarding, setShowOnboarding] = useState(false); + const [showInstall, setShowInstall] = useState(false); + const [showPairing, setShowPairing] = useState(false); + const [showSplash, setShowSplash] = useState(() => { + const should = sessionStorage.getItem('ap_show_splash') === '1'; + if (should) sessionStorage.removeItem('ap_show_splash'); + return should; + }); + + const location = useLocation(); + const path = location.pathname; + const navigate = useNavigate(); + + const closeOnboarding = () => { + setShowOnboarding(false); + if (user) { + localStorage.setItem(`onboarding_dismissed_${user.email}`, 'true'); + } + }; + + const logout = () => { + localStorage.removeItem('ap_token'); + localStorage.removeItem('ap_user'); + localStorage.removeItem('onboarding_dismissed'); + setUser(null); + navigate('/login', { replace: true }); + setTimeout(() => window.location.reload(), 50); + }; + + // Public routes — don't show the shell + if (path === '/login' || path === '/register' || path === '/change-password' || path === '/') { + return ( + + + + } /> + } /> + } /> + } /> + + + + + ); + } + + if (path.startsWith('/test-cases/studio')) { + return ( + + + {showSplash && setShowSplash(false)} />} + + } /> + } /> + + + + + + ); + } + + return ( + + + {showSplash && setShowSplash(false)} />} +
+ + +
+
+ +
+ + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ + {showOnboarding && } + {showInstall && setShowInstall(false)} />} + {showPairing && setShowPairing(false)} />} + + + {lightbox && ( +
setLightbox(null)}> +
setLightbox(null)}>✕
+ Screenshot +
+ )} + +
+
+
+ ); +} diff --git a/ui/src/api/apiClient.js b/ui/src/api/apiClient.js new file mode 100644 index 0000000..490dad8 --- /dev/null +++ b/ui/src/api/apiClient.js @@ -0,0 +1,30 @@ +export function getToken() { return localStorage.getItem('ap_token'); } +export function getUser() { try { return JSON.parse(localStorage.getItem('ap_user') || 'null'); } catch { return null; } } + +// Central API fetch — injects JWT on every request +export async function api(path, opts = {}) { + const token = getToken(); + + if (!token && window.location.pathname !== '/login' && window.location.pathname !== '/register') { + localStorage.removeItem('ap_user'); + window.location.href = '/login'; + return new Response(null, { status: 401 }); + } + + const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) }; + if (token) headers['Authorization'] = `Bearer ${token}`; + const res = await fetch(path, { ...opts, headers }); + if (res.status === 401 || res.status === 403) { + if (window.location.pathname !== '/login' && window.location.pathname !== '/register') { + const userStr = localStorage.getItem('ap_user'); + if (userStr) { + window.dispatchEvent(new Event('ap_session_expired')); + } else { + localStorage.removeItem('ap_token'); + localStorage.removeItem('ap_user'); + window.location.href = '/login'; + } + } + } + return res; +} diff --git a/ui/src/assets/hero.png b/ui/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/ui/src/assets/hero.png differ diff --git a/ui/src/assets/react.svg b/ui/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/assets/vite.svg b/ui/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/ui/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/ui/src/components/common/PageComponents.jsx b/ui/src/components/common/PageComponents.jsx new file mode 100644 index 0000000..dbd2508 --- /dev/null +++ b/ui/src/components/common/PageComponents.jsx @@ -0,0 +1,86 @@ +import { Link } from 'react-router-dom'; + +export function PageHeader({ title, crumb, actions }) { + return ( +
+
+

{title}

+
Home{crumb || title}
+
+ {actions &&
{actions}
} +
+ ); +} + +export function TableCard({ title, headerRight, search, onSearch, entries, onEntries, children, total, page, onPage }) { + return ( +
+
+

{title}

{total !== undefined &&

{total} record{total !== 1 ? 's' : ''} found

}
+ {headerRight} +
+
+
+
+ Show entries +
+
+
+ {onSearch !== undefined && ( +
+ 🔍 + onSearch(e.target.value)} /> +
+ )} +
+
+
{children}
+ {onPage && ( +
+ Showing {Math.min(total, entries)} of {total} +
+ + + +
+
+ )} +
+ ); +} + +export function ItemPicker({ items, selected, onToggle, getLabel, getSub, getMeta }) { + return ( +
+
+ {items.length} available + {selected.length} selected +
+
+ {items.length === 0 ? ( +
No items available yet.
+ ) : items.map(item => { + const checked = selected.includes(item.id); + return ( +
onToggle(item.id)}> +
+
{getLabel(item)}{getSub && {getSub(item)}}
+ {getMeta &&
{getMeta(item)}
} +
+ ); + })} +
+
+ ); +} + +export function Card({ title, children, style }) { + return ( +
+ {title &&

{title}

} +
+ {children} +
+
+ ); +} diff --git a/ui/src/components/common/PrivateRoute.jsx b/ui/src/components/common/PrivateRoute.jsx new file mode 100644 index 0000000..d7b2edc --- /dev/null +++ b/ui/src/components/common/PrivateRoute.jsx @@ -0,0 +1,16 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { getToken, getUser } from '../../api/apiClient'; + +export function PrivateRoute({ children }) { + const token = getToken(); + const user = getUser(); + const location = useLocation(); + + if (!token) return ; + + if (user?.requiresPasswordChange && location.pathname !== '/change-password') { + return ; + } + + return children; +} diff --git a/ui/src/components/common/SplashScreen.jsx b/ui/src/components/common/SplashScreen.jsx new file mode 100644 index 0000000..f6f2373 --- /dev/null +++ b/ui/src/components/common/SplashScreen.jsx @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; + +export function SplashScreen({ onDone }) { + const [phase, setPhase] = useState('hold'); // 'hold' | 'out' + + useEffect(() => { + const t2 = setTimeout(() => setPhase('out'), 1600); + const t3 = setTimeout(() => onDone(), 2100); + return () => { clearTimeout(t2); clearTimeout(t3); }; + }, [onDone]); + + return ( +
+
+
+
AutoPilot
+
+

Autonomous Testing Platform

+
+
+ ); +} diff --git a/ui/src/components/common/ToastContainer.jsx b/ui/src/components/common/ToastContainer.jsx new file mode 100644 index 0000000..92612c0 --- /dev/null +++ b/ui/src/components/common/ToastContainer.jsx @@ -0,0 +1,33 @@ +import { useState } from 'react'; + +let _toastId = 0; +let _setToasts = null; + +// eslint-disable-next-line react-refresh/only-export-components +export function toast(type, title, msg) { + if (!_setToasts) return; + const id = ++_toastId; + _setToasts(p => [...p, { id, type, title, msg }]); + setTimeout(() => _setToasts(p => p.map(t => t.id === id ? { ...t, hide: true } : t)), 3200); + setTimeout(() => _setToasts(p => p.filter(t => t.id !== id)), 3600); +} + +export function ToastContainer() { + const [toasts, setToasts] = useState([]); + // eslint-disable-next-line react-hooks/globals + _setToasts = setToasts; + const icons = { success: '✅', error: '❌', info: 'ℹ️' }; + return ( +
+ {toasts.map(t => ( +
+
{icons[t.type]}
+
+
{t.title}
+ {t.msg &&
{t.msg}
} +
+
+ ))} +
+ ); +} diff --git a/ui/src/components/layout/Header.jsx b/ui/src/components/layout/Header.jsx new file mode 100644 index 0000000..07c30a1 --- /dev/null +++ b/ui/src/components/layout/Header.jsx @@ -0,0 +1,23 @@ +import { useNavigate } from 'react-router-dom'; +import { Sun, Moon, Menu, Settings, Download, Link2, LogOut } from 'lucide-react'; +import { useTheme } from '../../context/ThemeContext'; + +export default function Header({ user, profileOpen, setProfileOpen, setSidebarOpen, setShowOnboarding, setShowPairing, logout }) { + const navigate = useNavigate(); + const { resolvedTheme, toggleTheme } = useTheme(); + + return ( +
+ +
+ {/* Theme Toggle */} + + +
+
+ ); +} diff --git a/ui/src/components/layout/Sidebar.jsx b/ui/src/components/layout/Sidebar.jsx new file mode 100644 index 0000000..d1ccbb4 --- /dev/null +++ b/ui/src/components/layout/Sidebar.jsx @@ -0,0 +1,121 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { useContext, useState, useEffect, useRef } from 'react'; +import { AuthContext } from '../../context/AuthContext'; +import { + LayoutDashboard, Play, History, CalendarClock, GitMerge, + PackageOpen, FlaskConical, SlidersHorizontal, Database, Globe, + MonitorDot, Users2, Download, + BarChart2, Eye, Share2, + Settings, Users, ShieldCheck, KeyRound, Bell, ScrollText, Trash2, LogOut, FolderOpen, Puzzle +} from 'lucide-react'; + +function NavItem({ to, icon: Icon, label, active, onClick }) { + if (onClick) { + return ( + + ); + } + return ( + + + {label} + + ); +} + +export default function Sidebar({ user, sidebarOpen, path }) { + const { setShowInstall, logout } = useContext(AuthContext); + const [profileOpen, setProfileOpen] = useState(false); + const profileRef = useRef(null); + const navigate = useNavigate(); + const is = (p) => path === p; + + useEffect(() => { + function handleClickOutside(event) { + if (profileRef.current && !profileRef.current.contains(event.target)) { + setProfileOpen(false); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + if (!sidebarOpen) return null; + + return ( + + ); +} diff --git a/ui/src/components/modals/InstallAgentModal.jsx b/ui/src/components/modals/InstallAgentModal.jsx new file mode 100644 index 0000000..eb9cb2e --- /dev/null +++ b/ui/src/components/modals/InstallAgentModal.jsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { api } from '../../api/apiClient'; +import { toast } from '../common/ToastContainer'; + +export function InstallAgentModal({ onClose }) { + const [agentOsTab, setAgentOsTab] = useState('windows'); + const [pairingCode, setPairingCode] = useState(''); + const [pairingLoading, setPairingLoading] = useState(false); + + const handleVerifyPairing = async () => { + if (pairingCode.length !== 6) { + toast('error', 'Invalid Code', 'Pairing code must be 6 digits'); + return; + } + setPairingLoading(true); + try { + const res = await api('/api/pairing/verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: pairingCode }) + }); + if (res.ok) { + toast('success', 'Success', 'Agent paired successfully!'); + onClose(); + setTimeout(() => window.location.reload(), 1000); + } else { + const data = await res.json(); + toast('error', 'Failed', data.error || 'Invalid pairing code'); + } + } catch { + toast('error', 'Error', 'Failed to connect to server'); + } + setPairingLoading(false); + }; + + return ( +
+
+
+

+ 🚀 Connect your first agent +

+ +
+
+

+ To start running tests, you need to install the AutoPilot agent on your machine or server. +

+
+ {['windows', 'mac', 'linux'].map(os => ( + + ))} +
+ +
+

1. Install the Agent

+

+ Download and run the installer on your Windows machine. +

+ + 📦 Download .msi Installer + + +

2. Enter Pairing Code

+

+ When the agent starts, it will display a 6-digit code. Enter it below to securely connect your machine. +

+ +
+ setPairingCode(e.target.value.replace(/\D/g, ''))} + style={{ + fontSize: '24px', + letterSpacing: '8px', + textAlign: 'center', + width: '200px', + padding: '12px', + borderRadius: '8px', + border: '2px solid var(--border)', + background: 'var(--surface)', + color: 'var(--txt)' + }} + /> + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/modals/OnboardingWizard.jsx b/ui/src/components/modals/OnboardingWizard.jsx new file mode 100644 index 0000000..488d895 --- /dev/null +++ b/ui/src/components/modals/OnboardingWizard.jsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; +import { useAuth } from '../../context/AuthContext'; +import { InstallAgentModal } from './InstallAgentModal'; +import { Rocket, Cloud } from 'lucide-react'; + +export function OnboardingWizard({ onClose }) { + const { user } = useAuth(); + const [step, setStep] = useState(1); + + if (step === 3) { + // Re-use the existing InstallAgentModal for the final step + return ; + } + + return ( +
+
+ + + {step === 1 && ( +
+
+

+ Welcome to AutoPilot, {user?.fullName || 'there'}! +

+

+ You're joining {user?.orgName || 'your workspace'}.
+ AutoPilot is your command center for autonomous end-to-end testing. + Let's get you set up in less than 2 minutes. +

+ +
+ )} + + {step === 2 && ( +
+
+

+ How AutoPilot Works +

+
+
    +
  1. Design your tests in the cloud dashboard.
  2. +
  3. Connect a local execution agent on your machine.
  4. +
  5. Run tests securely on your own infrastructure.
  6. +
+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/ui/src/components/modals/PairAgentModal.jsx b/ui/src/components/modals/PairAgentModal.jsx new file mode 100644 index 0000000..754b430 --- /dev/null +++ b/ui/src/components/modals/PairAgentModal.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react'; +import { api } from '../../api/apiClient'; +import { toast } from '../common/ToastContainer'; + +export function PairAgentModal({ onClose }) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (code.length !== 6) { + toast('error', 'Code must be exactly 6 digits'); + return; + } + setLoading(true); + try { + const res = await api('/api/pairing/verify', { + method: 'POST', + body: JSON.stringify({ code }) + }); + if (res.ok) { + toast('success', 'Agent successfully paired with organization!'); + onClose(); + } else { + const data = await res.json(); + toast('error', data.error || 'Failed to pair agent'); + } + } catch { + toast('error', 'Network error'); + } + setLoading(false); + }; + + return ( +
+
e.stopPropagation()} style={{ maxWidth: '400px' }}> + +

Pair Local Agent

+

+ Enter the 6-digit code displayed by your AutoPilot Local Agent application to securely link it to your cloud organization. +

+ +
+
+ + setCode(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))} + placeholder="000000" + style={{ fontSize: '2rem', textAlign: 'center', letterSpacing: '0.5rem', padding: '16px', fontFamily: 'monospace' }} + required + autoFocus + /> +
+ +
+
+
+ ); +} diff --git a/ui/src/components/modals/SessionExpiredModal.jsx b/ui/src/components/modals/SessionExpiredModal.jsx new file mode 100644 index 0000000..dbcbe90 --- /dev/null +++ b/ui/src/components/modals/SessionExpiredModal.jsx @@ -0,0 +1,16 @@ +import { useEffect } from 'react'; + +export function SessionExpiredModal() { + useEffect(() => { + const forceLogout = () => { + localStorage.removeItem('ap_token'); + localStorage.removeItem('ap_user'); + window.location.href = '/login'; + }; + + window.addEventListener('ap_session_expired', forceLogout); + return () => window.removeEventListener('ap_session_expired', forceLogout); + }, []); + + return null; +} diff --git a/ui/src/components/ui/alert.jsx b/ui/src/components/ui/alert.jsx new file mode 100644 index 0000000..bc53c0c --- /dev/null +++ b/ui/src/components/ui/alert.jsx @@ -0,0 +1,78 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}) { + return ( +
+ ); +} + +function AlertTitle({ + className, + ...props +}) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} /> + ); +} + +function AlertDescription({ + className, + ...props +}) { + return ( +
+ ); +} + +function AlertAction({ + className, + ...props +}) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/ui/src/components/ui/avatar.jsx b/ui/src/components/ui/avatar.jsx new file mode 100644 index 0000000..34cdf97 --- /dev/null +++ b/ui/src/components/ui/avatar.jsx @@ -0,0 +1,105 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}) { + return ( + + ); +} + +function AvatarBadge({ + className, + ...props +}) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} /> + ); +} + +function AvatarGroup({ + className, + ...props +}) { + return ( +
+ ); +} + +function AvatarGroupCount({ + className, + ...props +}) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/ui/src/components/ui/badge.jsx b/ui/src/components/ui/badge.jsx new file mode 100644 index 0000000..6a07934 --- /dev/null +++ b/ui/src/components/ui/badge.jsx @@ -0,0 +1,47 @@ +import * as React from "react" +import { cva } from "class-variance-authority"; +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ); +} + +export { Badge, badgeVariants } diff --git a/ui/src/components/ui/breadcrumb.jsx b/ui/src/components/ui/breadcrumb.jsx new file mode 100644 index 0000000..cb11cfd --- /dev/null +++ b/ui/src/components/ui/breadcrumb.jsx @@ -0,0 +1,121 @@ +import * as React from "react" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" +import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react" + +function Breadcrumb({ + className, + ...props +}) { + return ( +